Commit Graph
70 Commits
Author SHA1 Message Date
Juan José Mata fd6f4ff078 Add live AI checks to system health (#3155)
* Add live AI checks to system health

Give super admins a dedicated AI status view with bounded liveness probes for LLMs, vector stores, pgvector, and embedding endpoints. Record sanitized failures in both the system debug log and Rails logger, and document the recommended local configuration.\n\nCloses #3145

* Fix AI health CI checks

* Address AI health review feedback

* Correct Ollama model preload guidance

* Distinguish OpenAI-compatible providers

* Make Ollama startup readiness explicit

* Recognize Cloudflare AI endpoints
2026-08-24 22:41:08 +02:00
735b62d9c7 Track self-custody wallets natively: Bitcoin, EVM and Solana (#3081)
* feat(onchain-wallets): foundation for self-custody wallet tracking

Adds the schema, models and the normalised contract every chain will
produce, with no chain implemented yet.

The central constraint of a multi-chain integration is that `case chain`
must not spread through the importer, processor, controller and views.
So a chain adapter's only job is to turn an address into an
Onchain::Snapshot — a list of Onchain::Assets and Onchain::Movements —
and Onchain::Chains is the single source of truth for which chains exist,
how their addresses are validated, what their native asset is, and which
adapter to instantiate. Everything downstream is written once.

Two tables:
  - onchain_wallet_items: the family-level connection. Keyless by
    default; the only credential is an optional Etherscan key, encrypted
    via `encrypts`.
  - onchain_wallet_accounts: one row per asset, per address, per chain.

Uniqueness uses three partial unique indexes, one per asset kind, because
the identity of an asset depends on its kind: a native coin is identified
by its address alone while a token is identified by its contract/mint. A
single index over all columns would treat two native rows with a NULL
contract_address as distinct and let duplicates through — the model test
proves the rejection comes from the database by saving with
`validate: false`.

The schema also leaves room for extended-key (xpub) wallets later: an HD
wallet is a set of derived addresses under one item, which the current
uniqueness key already allows, so adding it needs no destructive change.

db/schema.rb is hand-edited to add only the two new tables: regenerating
it with the Rails version now in the Gemfile reorders every column in the
file, which is out of scope for this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onchain-wallets): chain-agnostic importer, processor and syncer

The whole pipeline is written once here and never branches on chain: it
consumes Onchain::Snapshots, so a chain is only ever a registry key and an
adapter.

Importer: refreshes every tracked address and records a digest of what it
saw (quantity plus movements, no timestamps) on each row. It deliberately
never creates rows — real wallets are full of spam airdrops, so a newly
seen token becomes trackable only when the user ticks it. An asset that
disappears from a wallet goes to zero rather than going stale.

Syncer: reprocesses only the rows whose digest changed. Two consecutive
syncs of an idle wallet write nothing at all — no row updates, no
holdings, no entries, and no queued account syncs. Both the importer and
syncer tests for that were checked against the pre-fix behaviour: with the
content_hash guard removed they fail.

Processor: writes the holding, the account balance and the movements.
Movements materialise two ways. When that day's price is known, a signed
trade (positive = Buy, negative = Sell) so cost basis and the value chart
reconstruct back to acquisition. Otherwise a display-only entry with
amount 0 and excluded: true, raw movement preserved in `extra` — visible,
but not inventing a value that would distort the account's history. Prices
are matched on the exact day for trades, because valuing a two-year-old
transfer at today's price would fabricate a cost basis.

Onchain::SecurityResolver binds a "CRYPTO:<SYMBOL>" ticker straight to the
crypto price provider instead of going through provider search, where
"USDC" can come back as a EUR-quoted pair and then need FX to repair. It
reuses an existing Security for the ticker whatever its MIC, so one asset
never splits into two records. Symbol normalisation for bridged
stablecoins and the price backfill follow in the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onchain-wallets): canonical asset symbols and price backfill

Two things stood between a linked wallet and a correct valuation.

Bridged and wrapped variants. The same dollar is USDC on Ethereum, USDC.e
on Arbitrum and USDbC on Base; the same ether is ETH natively and WETH
once wrapped. Left alone each variant becomes its own Security, so one
gets priced and the others sit at zero, and the same asset held on two
chains reports two different values. Onchain::AssetSymbol maps the
variants that are redeemable 1:1 onto the canonical asset, so pricing them
as that asset is exact rather than approximate.

Missing price history. A Security created at link time has none, so on the
first sync every movement would fall back to a display-only entry and the
cost basis would never reconstruct — the feature would look broken exactly
when the user first looks at it. The processor now backfills the window in
one batched provider call rather than one call per movement date, includes
today so a wallet whose movements all predate the sync window still gets a
current valuation, and treats a provider failure as a logged warning: the
holding and the quantity are still written.

All of it is a no-op when no crypto price provider is enabled, which is
the case the settings panel has to warn about before linking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onchain-wallets): Bitcoin adapter

Bitcoin has no account balances: an address owns unspent outputs, so the
balance is everything ever paid to it minus everything spent from it, and
mempool totals count — a broadcast-but-unconfirmed spend has already left
the wallet as far as its owner is concerned. Movements are the net effect
of each transaction on the address, so a self-transfer nets to zero and
produces no entry.

All three address formats are accepted (Base58 P2PKH/P2SH, bech32
segwit, bech32m taproot) with the character-set exclusions each encoding
actually has, and a malformed address is rejected before any request is
made — the test relies on WebMock failing the run if a request escapes.

Single address, not extended keys. A Bitcoin wallet is normally an HD
wallet: one xpub derives thousands of addresses and change goes to derived
ones, so tracking a single address under-reports such a wallet. Extended
keys would need BIP32 derivation (a dependency this codebase does not
want) or a descriptor-indexing backend. The limitation is stated in the
adapter, will be stated in the linking UI, and an xpub is rejected as an
address rather than silently treated as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onchain-wallets): EVM adapter for six networks, two backends

One adapter class serves every EVM network: a network's identity — label,
native coin, explorer URL, whether a family-supplied Etherscan key applies
— is data in the chain registry, so adding a network is an entry there
rather than a branch here.

The unit tracked is the (chain, address) couple, carried by the unique
index. A 0x address is valid on all six networks and holds different
balances on each, so detection asks each candidate network whether the
address is worth tracking there. That probe is exactly one request and
never reads paginated history: Blockscout's address summary carries the
coin balance and the token/transfer flags together, which is also why a
wallet holding only ERC-20 tokens with zero native balance is still found.
An explorer being down means "not detected here", not an error the user has
to interpret — a dead indexer must not break linking.

Two interchangeable backends behind Provider::EvmExplorer: keyless
Blockscout by default for every network, and Etherscan when the family
configured a key on a network the registry enables it for (today Ethereum
only), where a key buys nothing but a higher rate limit. Etherscan
deliberately does not implement the activity probe: its one-request answer
is the native balance, which reports "nothing here" for a token-only
wallet, so detection stays on the indexer that can answer correctly.

Zero-balance token rows are dropped — real wallets are full of spam dust —
while the native asset is always reported, even at zero, because a wallet
that spent everything still has a history worth keeping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onchain-wallets): Solana adapter

A Solana wallet does not hold its tokens. Each SPL token sits in its own
token account, owned by the wallet but addressed separately, so balances
come from enumerating those accounts across both token programs rather
than from reading the wallet address — and because one wallet can own
several accounts for the same mint, they are summed into one position.
Emptied token accounts are left behind on chain by design and are dropped.

RPC gives mints, not metadata. Well-known mints get their real symbol;
anything else is labelled with its mint in a form that deliberately cannot
pass for a ticker, so security resolution declines it and the asset is
tracked by quantity rather than priced as some unrelated coin that happens
to share a name.

Activity is one request. Bitcoin's Base58 addresses fall inside Solana's
address shape, so the two are told apart by asking the node: it rejects a
non-32-byte key, which reads as "not here" rather than an error.

The Snapshot it returns has the same shape as Bitcoin's and the EVM
adapter's — asserted in the test — so nothing downstream changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onchain-wallets): settings panel and linking flow

Linking is three steps: paste an address, confirm which network, choose
what to track.

The network step exists because address formats are not unique to a chain.
Candidates come from the address shape, and when there is more than one each
is asked whether the address is worth tracking there — one bounded request
each. When several answer yes, or none does, the user picks from a list that
marks which ones showed activity. Silently keeping the first match would
link the wrong network and surface later as a sync bug.

The token step imports nothing that was not ticked. Assets whose symbol a
price provider can quote are pre-checked; spam airdrops, whose "symbol" is
usually an advertisement, are listed unticked and can still be tracked by
quantity. Quantities and metadata are re-read from the chain when the
selection is applied, so a tampered selection can only change which assets
are tracked, never what they claim to hold. Previewing an address creates
no connection record, so an abandoned flow leaves nothing behind.

The panel and both modal steps carry a price-provider warning: with no
crypto market data enabled every wallet is valued at zero, which users
report as a broken sync rather than a missing setting, so it is said before
linking and links to where to fix it. The Bitcoin single-address limit and
"never enter a seed phrase" are stated in the linking UI too.

Errors are separated by kind. A rate limit or an unreachable explorer gets
its own localized message. Anything else is a bug: the user sees a generic
message and the class and message go to DebugLogEntry, never into the
response — asserted in the tests, which also check the exception text does
not appear in the body.

Adapters now translate their data source's errors into Onchain::Chains
errors, so the controller rescues two chain-agnostic types instead of
carrying a list of every explorer's error classes — which would have put
per-chain knowledge back into the controller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onchain-wallets): manage, review tokens, change address, disconnect

Four actions, because two would make the token choice irrevocable.

Review tokens reopens the selection screen with the address left alone —
deliberately without an address field. Without it the only way to untick a
token would be to change the address, which is a different operation
entirely. Assets the chain no longer reports stay listed and ticked, so
they can be dropped once they are gone.

Disconnect one asset is a per-row action with a button next to every asset.
A destroy route no view calls is dead code: the feature does not exist
until it has a button, so the test asserts one form per asset rather than
one per wallet.

Change address updates the existing rows instead of recreating them, so the
accounts, holdings, entries and balance history all survive — verified by
asserting a pre-existing balance is still there afterwards, and checked
against the recreate-instead-of-update behaviour, which fails it. The
content digest is cleared so the next sync reprocesses even if the new
address happens to hold the same amount, and an account still carrying its
generated name is renamed to match.

Disconnect wallet drops every asset at one address and leaves other
addresses alone.

Disconnecting never destroys an account: the provider link goes, holdings
are detached, and what the user can see stays as a manual account that
stops updating — the same contract every other provider's unlink has here.

The duplicate-address guard covers initial linking as well as address
changes. Without it, re-linking an already-tracked address would become the
unofficial way to add a token, quietly creating a second set of rows for
the same wallet; both guards are checked against that pre-fix behaviour.
Every lookup and mutation is scoped through Current.family, including the
per-row disconnect, which cannot reach a row belonging to another
connection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(onchain-wallets): hosting guide for self-custody wallet tracking

docs/hosting/onchain-wallets.md covers what the feature reads, which
endpoint serves each network and how to point it at your own instance, the
optional Etherscan key and why it is optional, the per-sync request cost and
where history is capped, and the management actions.

Two things get stated plainly because they generate the support traffic:
prices come from a separate market data setting, so with no crypto-capable
provider enabled every wallet is tracked by quantity and valued at zero; and
Bitcoin is one address at a time, which under-reports an HD wallet whose
funds are spread across derived addresses.

Every locale key the feature uses is checked to resolve, including the
pluralised ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): keep EVM balances on the keyless indexer when a key is set

Configuring an Etherscan key moved every read onto Etherscan, including
balances. That was wrong: Etherscan has no free endpoint that enumerates an
address's tokens, so its token balances are summed from transfer history —
which cannot see a rebasing token's current balance, and silently
under-reports any wallet whose history exceeds the page cap. A user adding a
key to fix a rate limit would have quietly traded it for wrong balances.

The two reads are now separated by what each backend can actually answer.
Balances and activity detection always go to the keyless indexer, whose
address summary answers both in one request — so adding a key buys nothing
there and cannot cost anything either. History, the paginated and
rate-limited half, is where a key helps and is the only thing it changes.

Provider::Etherscan drops its balance methods and refuses token_balances
with the reason, rather than offering an approximation that reads as a fact.
The chain-agnostic error mapping now accepts several error families per role,
since one snapshot can involve both backends.

Checked against the pre-fix behaviour: with balances routed through the
keyed backend, the new test reports 1 USDC held instead of the 7 the indexer
sees, and Etherscan raises on the balance call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onchain-wallets): name verified SPL tokens so they can be priced

Solana RPC returns mints, not names, so every SPL token outside the
hard-coded handful showed up as "SPL:abcd…wxyz" — which security resolution
correctly declines, leaving the asset tracked by quantity and valued at zero.
For a wallet holding anything beyond USDC that was most of its value.

Names now come from Jupiter's keyless token search, asked once per snapshot
for every mint at once, cached 24 hours per mint.

Only mints the list reports as *verified* are trusted. Anyone can mint a
token calling itself USDC; naming an unverified one would hand it the real
dollar's price and value dust at thousands. Unverified and unknown mints keep
the placeholder, and the test for that asserts the spam token is not named
USDC. Misses are cached as well, because spam wallets hold many mints that
will still be unvouched-for tomorrow.

Metadata is a naming nicety, not the wallet: a list that is down or rate
limiting degrades to placeholders instead of failing the snapshot, and the
hard-coded mints stay as an offline floor. Assets and movements resolve from
the same lookup, so a token cannot be called two different things within one
snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onchain-wallets): enable crypto pricing in one click, and log zero valuations

The warning said what was wrong and where to fix it, but still made the user
navigate to another settings page and pick the right provider out of a list.
On a self-hosted instance an admin can now fix it from the warning itself:
the crypto provider is appended to the enabled securities providers, leaving
the others alone — enabling crypto prices must not turn off whatever prices
the user's equities, and the test fails if it does. On a managed instance the
providers are the operator's setting, so the button is not offered and the
action refuses.

The other half is diagnosis. Until now a holding valued at zero for this
reason looked exactly like a holding whose price simply had not been fetched
yet: nothing recorded it, so support had to infer it from a screenshot. The
processor now records it via DebugLogEntry — but only for this cause, since a
price merely missing for today is ordinary and already covered by the
backfill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onchain-wallets): upgrade display-only movements once their price is known

A wallet linked before market data covered its history got the worst of both
worlds: its transfers landed as zero-amount excluded entries, and nothing ever
brought them back. The syncer only reprocesses assets whose on-chain state
changed, and a two-year-old transfer never changes — so the cost basis stayed
broken for exactly the wallets that were linked earliest.

perform_post_sync now runs a repair over every linked asset, not just the ones
that moved: what changed is the price history, which no chain read can report.
It reads prices from the database only, makes no network call, and does
nothing when there is nothing to upgrade.

An upgraded entry keeps its external_id, so it is the same transfer rather
than a duplicate. The entry is destroyed and rewritten because an Entry cannot
change entryable type in place and import_trade refuses an id already held by
a Transaction. Entries from other providers are never touched — matched by
source and by this asset's own id prefix.

Checked against the pre-fix behaviour: with perform_post_sync empty again, the
syncer test fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onchain-wallets): make history truncation visible and its depth configurable

History was capped silently. A wallet with more transfers than one sync reads
looked exactly like a wallet whose history was fully imported — the only trace
was a Rails log line on Bitcoin, and nothing at all on the EVM and Solana
paths. A user reconciling their cost basis had no way to tell the difference
between "this is all there was" and "we stopped reading".

Each source now reports whether it stopped on the budget or on the end of the
history. That travels on the Snapshot, so it stays chain-agnostic; the flag is
recorded on the affected rows, the manage screen says the history is
incomplete for that address, and the importer records it once per address per
sync — only when something changed, so an idle wallet with deep history does
not log the same line every night. The message also states what is not
affected: balances come from an address summary, never from history.

The depth itself is a hosting decision, not a property of a chain, so it moves
out of the providers into Onchain::HistoryBudget and is settable with
ONCHAIN_HISTORY_MAX_PAGES (default 10, clamped to 200). Adapters inject it, so
the providers stay unaware of the policy and the budget is testable without
touching the environment.

What this does not do is resume where it stopped. Reading older history across
syncs needs a per-wallet cursor and changes what the content digest means —
which is what guarantees an idle wallet writes nothing — so it is a change of
its own rather than a rider on this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): read Blockscout token balances the way the API accepts

Verified against the live API: /api/v2/addresses/{address}/token-balances
rejects a `type` filter with HTTP 422 — the filter exists on token-transfers,
not here. Every EVM snapshot was therefore failing in production and
surfacing as "the explorer could not be reached", while the tests passed
because the stub encoded the same wrong assumption. No unit test can catch a
mistaken API contract; only asking the API can.

Without the filter the endpoint answers with every token standard the address
holds — ERC-20, ERC-721, ERC-1155, ERC-404 — so ERC-20 is now selected
client-side. An NFT row carries value "1" and no decimals, so it would have
been imported as a fungible balance of one token, priced by whatever its
symbol happened to resemble.

The tests now stub the endpoint the way it really answers and assert we ask
without a query, so re-adding the filter fails the run. Each token's
market-cap signal is carried through for the next commit, which has to decide
what to do about an address holding thousands of tokens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): bound how many tokens one address can surface

Probing the live APIs turned up the other thing the stubs were hiding: real
addresses are airdrop dumping grounds. A well-known Ethereum address returns
7,924 ERC-20 balances (3.2 MB), and a comparable Solana one 2,801 token
accounts. Nothing bounded either. That meant a review screen with thousands of
rows nobody can use, and on Solana — where names are looked up in batches of
50 — around 56 extra requests per sync just to label an airdrop dump.

One read now surfaces at most 200 tokens per address
(ONCHAIN_MAX_TOKENS_PER_ADDRESS, clamped to 5,000). The native coin is never
affected, and anything already tracked keeps syncing regardless of the cap.

What survives the cap has to be both sensible and stable. On EVM the tokens
are ranked by the market cap the indexer already reports, so real assets stay
and airdrops fall off the end. Solana RPC gives no such signal, so the order
is the mint address: arbitrary, but identical between two reads of an unchanged
address — an unstable order would reshuffle the wallet, change the content
digest, and rewrite holdings every night. There is a test for that on both
chains. On Solana the cap is applied before metadata lookup, so it bounds the
requests as well as the rows.

The cap is not silent: it is recorded on the affected rows, stated in the token
review screen and in Manage wallets, and reported alongside history truncation
in the debug log with the limit that applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(onchain-wallets): walk the linking and management flow in a browser

The linking flow stacks three Turbo frame navigations — the provider drawer,
the linking modal on top of it, then the token review in that same modal —
and no controller test exercises any of that. Driving it in a real browser
found two things worth keeping:

The panel is reached two different ways. With nothing linked yet it is a card
under "Available" that opens the drawer; once a wallet exists the provider
moves to "Your connections", where the panel renders inline inside a
disclosure and there is no link to click at all. Only the first path had ever
been exercised.

Both paths now are, along with the parts of the flow that only exist in a
browser: assets arriving pre-ticked or not according to whether they can be
priced, review tokens reopening the selection with no address field present,
and disconnecting one asset leaving its account behind. One test per flow —
the branching cases stay in the controller test, where they cost a fraction
of the time.

Verified visually at each step: the warning banner, the Bitcoin
single-address note, the three-asset review with the spam token unticked, the
four management actions, and the accounts landing under Crypto with the wallet
subtype.

Full system suite green the documented way (DISABLE_PARALLELIZATION=true):
94 runs, 385 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): report a timed-out data source as unreachable

Reading a real Solana wallet turned up the hole: the public RPC timed out,
Net::ReadTimeout escaped every layer untranslated, and the user was told
something had gone wrong with Sure — the generic message reserved for our own
bugs — with a DebugLogEntry filed as if it were one. A public endpoint being
slow is the most ordinary failure this feature has.

All five clients now translate transport failures into their own ApiError:
timeouts, refused or reset connections, unreachable hosts, TLS errors, and a
response that is not JSON. The adapters already map a provider's own errors
onto Onchain::Chains::UnreachableError, so a timeout now reads as "the public
explorer could not be reached", the message that tells the user to retry,
while genuine bugs keep the generic one. During chain detection it goes back
to meaning "not detected here", so a slow explorer still cannot break linking.

The translated message carries only the error class, never the original
message, because a transport error's message contains the full URL and these
get logged.

Checked against the pre-fix behaviour on Bitcoin: with the translation
removed, the timeout test fails with the raw exception instead of the chain
error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): name on-chain trades after the asset, not "shares"

Running a real Bitcoin address through the app showed every imported transfer
as "Buy 0.000003 shares of CRYPTO:BTC". That name comes from the shared trade
helper, which is written for equities; a wallet does not hold shares, and the
internal ticker is not what the user calls the coin. Trades are now named
"Buy 0.000003 BTC", through i18n like the display-only entries already were.

Also drops the sync status_text calls. Sync has no such attribute in this
schema, so every one of them was a guarded no-op and the four locale strings
they referenced could never render — dead weight copied from another
provider's syncer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): keep balances when a source refuses history

Reading real wallets on the free Solana endpoint exposed two problems, one of
which I introduced.

The budget. Making history depth configurable scaled Solana's transaction cap
from 25 to 250, and on Solana one transaction is one RPC call rather than one
page — so the same nominal depth became an order of magnitude more expensive
and a sync went from seconds to minutes. The per-transaction budget is now its
own constant, scaled proportionally off the page knob so one setting still
moves both, with a test that pins it far below the paginated row count.

The bigger one: a failure while reading history threw away the balances too.
A balance is one bounded request and is what a wallet fundamentally is; history
is paginated, far more expensive, and the first thing a throttled endpoint
refuses. On the free Solana endpoint, which routinely throttles getTransaction,
that meant a wallet showed nothing at all rather than showing what it holds —
permanently, not transiently.

History is now best effort: when the data source refuses it, the balances are
still recorded and the history is marked incomplete, which the manage screen
already surfaces. Anything that is not the data source failing still raises, so
a bug here cannot be swallowed. Verified live: the same Solana wallet that
failed entirely now reports 52.06 SOL and its SPL tokens with the history
flagged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): pre-tick what is worth something, not what parses as a ticker

Linking a real airdropped address showed 200 of its 201 assets arriving
pre-ticked, one click away from 200 accounts — the opposite of what reviewing
tokens before import is for. The pre-tick rule was "the symbol looks like a
ticker", and airdrops use perfectly plausible short symbols (0XBTC, 4CHAN), so
it selected nearly everything.

Assets now carry whether the data source treats them as notable, and only those
are pre-ticked. Two attempts at that signal, decided by measuring the real
address rather than guessing:

  - Blockscout's `reputation` is "ok" for all 6,669 tokens it holds. Useless.
  - Market-cap presence looks strong on the full list (365 of 6,669) but is
    useless after the cap, which already ranks by market cap — hence every
    surfaced token having one.
  - Holding value discriminates: of the 365 priced tokens, 112 are worth more
    than a dollar.

So on EVM networks a token is notable when the indexer can price it and the
holding is worth more than a dollar; on Solana when the verified token list
vouches for the mint; the native coin always. Pre-ticked count on that address
drops from 200 to 72, and everything else stays one click away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): warn when nothing can convert USD prices into the family currency

A family whose currency is not USD needs two settings, not one, and only the
first was covered. The single provider that prices bare crypto symbols quotes in
USD, so valuing a wallet in EUR needs an exchange rate on top — and Sure's
default exchange rate provider requires an API key, so a self-hosted install
without one has no FX at all.

Tested against a real address in a EUR family: every wallet came out at zero,
with 275 transfers recorded as unpriced, and nothing anywhere said why. That is
the same support ticket the crypto-provider banner exists to prevent, arriving
through the other door.

Onchain::Pricing answers "can an on-chain asset be valued in this currency, and
if not, why" with the two reasons separately. The linking UI states whichever
applies — naming the currency for the FX one, and pointing at Frankfurter, which
needs no key — and a USD family never sees that warning because it needs no
conversion. The processor records the reasons when a holding lands at zero, so
support sees "exchange_rate" rather than guessing.

Verified live afterwards: with EXCHANGE_RATE_PROVIDER=frankfurter, the same EUR
family values the wallet at 416,198,342.25 EUR at 0.86363 USD/EUR, with all 275
transfers priced in EUR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): let a movement become a trade after being display-only

Syncing a real wallet twice — once before its prices were reachable, once after
— raised ArgumentError from the shared importer: an Entry cannot change
entryable type in place, and the display-only Transaction already held the
external_id the trade needed.

That is the ordinary case, not an edge one. A wallet linked before market data
covers its history gets display-only entries on the first sync, and the first
sync that can price them dies. Worse, it dies inside perform_sync, so the repair
pass that exists precisely to upgrade those entries — and which runs in
perform_post_sync, afterwards — was never reached. The account stayed stuck.

Both paths now go through one writer that discards a stale display-only entry
before the trade takes over its identity, so the entry keeps being the same
transfer rather than becoming a duplicate. Checked against the pre-fix
behaviour: without the discard, the new test raises the original ArgumentError.

Found by running a EUR family through two syncs on real data, which is the only
way the two price states occur in order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): write a trade and drop its display-only entry atomically

Replacing a display-only entry with the trade it became is one change, but it
was two writes outside a transaction. A failure between them left the account
with neither: the transfer disappeared until some later sync happened to rewrite
it, which for an idle wallet could be never.

The repair pass already wrapped this; the sync path did not, and that is the one
that runs first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): tell apart two transfers of one token in one transaction

A token transfer was identified by its transaction hash and contract, which are
not unique together: a swap router or a batch payout routinely emits several
transfers of the same token involving the same address within one transaction.
The second overwrote the first, so a transfer disappeared from the account
without a trace.

Both EVM backends report the log index — the field that makes an event unique
inside a transaction — and neither was using it. It is now part of the
identifier, with the contract kept only as a fallback for an instance that does
not report one.

Reported by review on #3081; confirmed against the live Blockscout payload,
which carries log_index. The test fails against the previous identifier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): canonicalise an address per chain before anything keys off it

Addresses were only stripped, never canonicalised, and what counts as the same
address differs by chain — so the duplicate guard could be walked straight past
and one Bitcoin case was worse than a duplicate.

On EVM, hex is hex: 0xABC… and 0xabc… are one wallet, but they linked as two,
each with its own accounts and holdings for the same balance.

On Bitcoin, bech32 is case-insensitive and canonically lowercase, and the API
reports outputs that way. An uppercase bech32 address passed validation, gave a
correct balance from the address summary, and matched no output at all — so the
wallet silently had zero movements and no cost basis, with nothing anywhere
saying why.

Canonicalisation is now the adapter's answer, since only the chain knows whether
case carries identity: EVM and bech32 fold, Base58 and Solana are left exactly
as given. The controller applies it as soon as the chain is known and before the
duplicate check, and on an address change too.

Reported by review on #3081. Both tests fail without the folding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): stop folding the case of Solana mints

Contract identifiers were downcased everywhere on the assumption that a contract
address is hex. That holds for ERC-20 but not for an SPL mint, which is a Base58
public key where case is part of the value: the stored mint was an unusable copy
of the real one, and two distinct mints could collide once folded into the same
string — one wallet's balance landing on the other's row.

Whether case carries identity is a property of the token kind, so it is now
answered in one place and applied consistently: by the asset's identity key, by
the column, and by the two comparisons in Onchain::Snapshot. A Movement no longer
folds anything on its own, because a movement does not know its asset's kind.

That also removes the duplication review flagged: the asset key was derived in
three places and only one of them folded, so the review screen and the linker
could disagree about what identifies an asset. There is one definition now,
OnchainWalletAccount#asset_key.

Reported by review on #3081. The test fails with the unconditional fold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): isolate a failing address, and let a late symbol land

Two problems in the importer, both reported by review on #3081.

One unreachable address took the whole connection down with it: the loop over a
family's addresses had no rescue, so a Bitcoin explorer being throttled left an
untouched Ethereum wallet unsynced as well. Each address is now recorded and
skipped on its own — a row we failed to read keeps its previous quantity rather
than being zeroed, since we did not learn that it holds nothing — and only a
connection whose every address failed is reported as a failed sync rather than a
quiet success.

The content digest covered quantity and movements but not the metadata written
alongside them, so a Solana mint that later gained a real symbol from the token
list produced the same digest as before: the row was never rewritten and its
placeholder label became permanent. That silently undid the token-naming work.
The digest now covers everything the update writes.

Both tests fail against the previous behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(onchain-wallets): use DS::Button for the wallet actions

Five hand-built button_to controls carried their own utility-class strings for
what DS::Button already does — sync, disconnect a connection, stop tracking one
asset, disconnect a wallet, enable crypto prices. They drifted from the design
system on size, hover and destructive treatment, and the confirm prompts were
wired by hand.

The browser test walks the two that matter, so the behaviour is unchanged; this
is the styling and the confirm handling moving to the component that owns them.

Reported by review on #3081, along with the raw `bg-amber-600` on the provider
badge — left as it is, deliberately: all 23 other providers set a raw palette
class for their badge, there is no functional token for a brand colour, and
changing one entry would make it the only different one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): four smaller findings from the review on #3081

**A failed read no longer leaves an empty connection.** `link_wallet` created the
family's connection before fetching the address, so an explorer being down left a
connection with no wallets showing in the panel as connected. Reading needs no
saved connection — which is exactly why previewing uses an unsaved one — so it is
now created only once the read succeeds.

**Assets that could not be tracked are named instead of blamed on the user.**
`success?` is `created.positive?`, and the only failure message was "pick at least
one asset to track" — so a user who ticked three assets and hit three failed
creates was told to tick something, sending them back to tick the same three.
The linker already collected which assets failed; the message now says so, and a
partial failure is reported alongside what did get tracked instead of being
dropped silently. Same fix in the token revision action, which had the same shape.

**A malformed stored amount costs its movement, not the asset.** `BigDecimal()`
on a stored payload raises, and one unparseable row would fail the whole asset's
processing — including the repair pass. These payloads are written by this code,
so it takes a row that survived a format change, but the containment is two lines.

**No dead-end link.** The warning offered "open market data settings" to
everyone, while that page is gated to self-hosted admins — the same gate the
enable button already had. Both controls are now behind it, and the warning text
stands on its own for everyone else.

Each has a test that fails against the previous behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): reject non-finite amounts and quantities

The guard added an hour ago for malformed stored amounts was incomplete for the
very case it was written for: BigDecimal parses "NaN", "Infinity" and
"-Infinity", and none of them is zero, so all three sailed through
`parse_amount` into trade materialisation. Verified rather than assumed — the
test fails without the check with PG::NumericValueOutOfRange, so the infinity
reached the insert.

Fixed at both layers that write numbers. Movements: a non-finite amount skips its
movement, like any other unparseable one. Quantities: `normalize` treats
non-finite as unknown and writes zero, because Postgres numeric stores NaN
happily and one NaN quantity would turn every total that reads it into NaN.

Reported by review on #3081.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): derive the content digest from what actually gets written

Third time the digest missed a field it was supposed to cover: first the symbol,
so a Solana placeholder that gained a real name was never rewritten; now the
truncation flags, so a wallet whose history became complete — or started being
capped — kept showing the old completeness in the UI, since the early return
fires before `extra` is touched.

Rather than add a third field to a hand-picked list, the digest is now taken from
exactly the attribute hash that is about to be written. The two cannot drift
apart, which is what kept going wrong.

That needs two things to hold. Movements are sorted before the payload is built,
so the order a source happened to list them in is not mistaken for a change. And
the hash is canonicalised before hashing, because jsonb does not preserve key
order: `extra` written as {history, assets} comes back as {assets, history}, and
hashing that raw made an idle wallet's digest flip every other sync — the tests
caught it, and it is now covered by one asserting that reordered movements are
not a change.

Reported by review on #3081. Both halves checked against the pre-fix behaviour:
without the flags two tests fail, without the canonicalisation three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(onchain-wallets): state the pricing coverage limit, and that DeFi is unseen

The hosting guide explained how to configure prices but never said what the
crypto provider actually covers. It quotes by symbol, and a symbol is not a
token's identity — measured on a real Ethereum address, two of its ten largest
token positions were quoted and the other eight, including holdings worth
roughly $406k, $141k and $74k, showed zero with their quantities tracked
correctly.

That reads as a broken sync unless it is written down, so it is now the first
limitation in the list, with the practical rule a user needs: a zero next to a
token you know is worth something means the provider does not list it, not that
the balance is wrong. Troubleshooting gains the matching entry, separating it
from the two configuration causes that produce a zero across every wallet.

Also records that DeFi positions — staked ETH, LP tokens, lending, Solana stake
accounts — are invisible, which was missing from the list entirely. A wallet
holding most of its value in a staking protocol reports a fraction of it.

Docs only; no code touched, so the suite was not re-run — the last run on this
tree was green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): do not zero an asset the token cap never reached

A tracked asset missing from a snapshot was always read as "the wallet no
longer holds it" and set to zero. That is right for a complete read, and wrong
for a capped one: at most 200 tokens per address are surfaced, so a tracked
token can be absent simply because the read stopped before reaching it. Its
balance was then wiped — a real holding removed from the user's net worth, with
a holding of zero written and the account balance reduced to match.

It is reachable: on Solana the surfaced set is ordered by mint address, which is
arbitrary, so a genuinely held USDC position on an airdropped wallet can fall
outside the cap and be zeroed on the next sync.

Absence of evidence is not evidence of absence — the same distinction this code
already makes for an address it could not read, where the row keeps its previous
quantity. When the snapshot is capped and the asset is not in it, the row now
keeps what was last known and only its completeness flags are refreshed.

Reported by review on #3081. The test fails against the previous behaviour, and
the "an asset that disappeared is set to zero" case still passes: a complete read
that no longer lists an asset still zeroes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(onchain-wallets): rename the local `token` variables the secret scan trips on

Pipelock failed the PR with eleven "Credential in URL (high)" findings, all on
the same shape: a local variable named `token` being assigned. `token = ...`
is what a leaked credential looks like to a scanner, and the rule is a
reasonable one to keep.

Renamed rather than excluded. Excluding paths or adding `# pipelock:ignore`
would have kept the pattern and blunted the check for everyone; the new names
are at least as clear — `token_data` for the raw hash from an indexer,
`metadata` for the resolved symbol/name pair, `token_asset` for an
Onchain::Asset in tests.

Verified with the scanner itself rather than by inference: the same pipelock
2.8.0 the workflow pins, run locally over the branch diff, now reports "No
secrets found in diff". It also caught one site the CI log had truncated away
(the system test), which is why the local run was worth setting up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): bound detection latency, and three review findings

Detection runs on the request thread but asked each candidate chain with a
sync's patience: a 30s timeout, three retries and exponential backoff, per
chain. A 0x address is a candidate on six networks, so one rate-limited
explorer could hold the page for minutes. Detection now reads with its own
budget - one short attempt, no retry, ONCHAIN_DETECTION_TIMEOUT to raise it -
while syncs keep the patient one. A chain that cannot answer in time is
reported as "no activity", which is the screen an ambiguous answer already
produces.

Also from review:

- A full page of Solana signatures is now reported as incomplete history.
  The adapter passes no cursor, so a page that fills means the read stopped
  short of the address's history; counting only against the transaction
  budget called that complete.
- A reused CRYPTO: security with no price provider is bound to the crypto
  one. A blank provider falls back to whichever is enabled first, and only
  the crypto provider quotes a bare coin symbol, so the holding valued at
  zero and read as a broken sync. A provider another integration chose is
  left alone: the CRYPTO: prefix is shared with the exchange integrations.
- The chain select's label reached the form builder as an HTML attribute
  instead of a label option, so no <label> was associated with the field.

Each regression test was checked against the pre-fix code: the two detection
tests fail with four requests instead of one, and the truncation test fails
by reporting complete history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): let the table refuse a token row with no contract

The partial unique indexes key a token on its contract address, and NULLs
are distinct to Postgres, so a token row that reached the table without one
would slip past its index and duplicate freely. The model already refuses
it, but a direct write does not go through the model, and this repo puts
simple guarantees like this in the database.

Added to the existing migration rather than a new one: the table is created
by this branch, so the constraint belongs with it, and this leaves the
schema version untouched.

The regression test writes with validate: false, which is how the sibling
tests prove a guarantee comes from the table rather than from the model
above it. Without the constraint it fails with "expected but nothing was
raised".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): detect a token-only Solana wallet, and pin asset_kind

Two findings from the review of the previous commits.

Detection read only the wallet's lamports, so a wallet emptied of SOL but
still holding SPL tokens answered "nothing here". Each token account carries
its own rent, so an empty wallet address is not an empty wallet. The token
accounts are asked only once the balance comes back zero, so the ordinary
case still costs the one request this probe is meant to be, and accounts
left behind empty do not count as activity.

The narrow blast radius is worth stating: has_activity? only runs when an
address matches more than one chain, and when no candidate answers the user
is asked to choose rather than turned away. So this was a worse screen, not
a rejected wallet.

Separately, the check constraint accepted any asset_kind that carried a
contract address. Each partial unique index names its kind, so a row with
any other one is keyed by nothing and duplicates freely. Adding a token kind
already means adding its index here, so pinning the three in the table adds
no coupling that the indexes did not already have.

Both regression tests fail on the previous code. The third test - emptied
token accounts are not activity - passes either way by design: it guards the
new branch rather than testing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(onchain-wallets): cover the asset_kind constraint that shipped without it

The constraint landed in b3dae571 but its regression test did not: the `git
add` named the test/models/onchain directory, and this file sits beside it
rather than inside it. Committing the test the constraint was written for.

Without the constraint it fails with "expected but nothing was raised".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): stop a slow explorer from reading as an empty wallet

Regression from the detection budget added two commits ago, caught by probing
the real endpoints rather than the stubs.

The 5s budget sat directly on base.blockscout.com's median latency (p50 4.79s,
slowest successful probe 9.5s), so roughly a fifth of probes timed out. The
timeout was swallowed into false, which detection cannot tell apart from "this
address is not here" — and when one other chain answers yes, a single yes
settles it. A Base wallet could therefore be linked as Ethereum-only, silently
and permanently.

Three changes, and they are one fix:

- A probe that cannot answer now returns nil rather than false, and a nil sends
  the user to the chain chooser instead of letting a lone yes settle it. Being
  slow and having nothing to report are different answers.
- Probes run concurrently under one absolute deadline. The client timeout could
  never bound the page anyway: HTTParty applies it per socket operation, so a
  "5s" probe was measured at 10.1s. The deadline is wall clock and covers the
  whole detection, whatever the client is doing.
- The timeout is 10s, with the reason for the number written down.

Measured against the live explorers, before and after: base false negatives
4/18 -> 0/10, and detection of a 0x address across six chains 9.4-10.7s ->
4.3-4.8s, because the cost is now the slowest chain rather than their sum.

The detector had no test of its own; it has four now, three of which fail on
the previous code, including the concurrency one by reporting "three 0.3s
probes took 0.9s, so they ran in sequence".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-22 21:27:38 +02:00
Josh 2f821e2567 chore(security): update Pipelock integration to 3.4.0 (#3122)
* chore(security): update Pipelock integration to 3.4.0

* fix(ci): validate shipped Pipelock configs

* fix(security): isolate external assistant profile

* fix(ci): build Helm dependencies before validation

* fix(ci): strengthen Pipelock contract checks
2026-08-22 05:41:00 +02:00
Brandon 25a9011f14 feat(ai): analytical tool-set upgrade for the builtin assistant (#3064)
* fix(assistant): survive tool failures with error and hint results instead of aborting the turn

A tool exception used to raise FunctionExecutionError out of the responder
loop, turning the whole turn into a generic chat error banner. An unknown
tool name was worse: the rescue block itself crashed (fn.name on nil).

Tool failures now come back to the model as data ({error, hint}) so the
conversation survives and the model can retry once with corrected
arguments. The catch-all branch logs and tells the model not to retry.
FunctionExecutionError remains defined for API compatibility.

* fix(assistant): strict schemas declare every property as required

get_categories and get_tags declared an optional page property while
inheriting strict mode, which is invalid under strict function calling
(every property must be listed in required). Both now opt out of strict
mode like every other paginated tool, and gain a page_size param
(1..100) while being touched.

A registry-walking test asserts the invariant for every current and
future tool, preview tools included.

* fix(assistant): HistoryTrimmer always keeps the newest turn

Trimming iterates newest-first and stopped at the first group over
budget. When the newest group alone exceeded the budget, everything was
dropped, including the user message the model was being asked to
answer, and the provider received only the system prompt. The newest
group now always survives.

* perf(assistant): compact AI time series and make account history opt-in

get_accounts shipped a 5-year monthly series for every account on every
call, roughly 60 formatted money strings per account, which dominates
the tool payload for multi-account families and swamps small
self-hosted context windows. The series is now opt-in
(include_balance_series) and bounded by a named period (series_period,
default last_365_days).

to_ai_time_series states the currency once and emits numeric values
instead of formatting every point; the system prompt already tells the
model how to render currency.

get_accounts also now returns account ids (they are what other tools
accept as account_ids filters) and respects the visible scope, so
hidden accounts no longer leak into responses.

* refactor(assistant): id and name filters replace user-data enums in get_transactions

The schema inlined every account, category, merchant, and tag name as
enum values on every request. That grows without bound with family
data, defeats provider prompt caching (definitions change whenever a
name does), and is the pattern that made empty-enum pruning necessary
in the first place.

Filters are now plain string arrays documented as exact names from the
sibling get_* tools, which Transaction::Search already resolves
server-side, plus an account_ids UUID filter. New params: page_size
(1..100), sort_by amount, types (income/expense/transfer, the way to
exclude transfers), and statuses (pending/confirmed).

The three now-unused enum helpers are removed from the base class;
family_tag_names stays for update_tag, which still identifies tags by
name.

* feat(assistant): add get_merchants and get_recurring_transactions

Merchants were unreachable: names appeared nowhere and
update_transaction's merchant_id had no source of ids, making it
unusable. get_merchants lists id, exact name, and source, scoped
through available_merchants_for so merchants seen only in accounts
hidden from the user never leak.

Recurring transactions had a model, an Upcoming view, and no assistant
reach. get_recurring_transactions lists detected and manual recurring
items (status filter defaulting to active, optional
upcoming_within_days window) with per-currency totals of active
non-transfer items, answering subscription and upcoming-bill questions
directly instead of via transaction paging.

* feat(assistant): flexible periods on get_balance_sheet and trends on get_income_statement

get_balance_sheet was hard-wired to five years of monthly history with
no parameters, although Period supports arbitrary ranges and the chart
builder takes any interval. It now accepts a named period or custom
dates plus an interval, with a 400-point cap so a day-granularity
request over a decade returns an error instead of a giant series. The
default call is byte-compatible with the old shape. The balance sheet
object is also memoized; it was being constructed four times per call.

get_income_statement gains the analysis surface the assistant lacked:
group_by month for a monthly income/expenses/net series (capped at 36
buckets), compare_previous_period for an equal-length prior window
with absolute and percent deltas, and account_ids to scope totals to
specific accounts via IncomeStatement#totals_for. Category breakdowns
are family-wide by construction, so the account-filtered view omits
them and says why. Unknown or inaccessible account ids come back as a
soft failure naming the ids so the model can correct itself.

* feat(assistant): preview reads for insights and valuations

The Insights feed is generated nightly with pre-computed numbers, and
the chat assistant could not read a word of it. get_insights returns
the visible feed (type filter, acknowledged toggle, limit) without
marking anything read; an assistant read is not the user viewing the
feed. It sits in PREVIEW_FUNCTION_CLASSES because the feature itself is
preview-gated, which also keeps it off the default /mcp surface.

record_valuation was write-only: an agent recording provenance-cited
valuations had no way to audit what it wrote or find dates already
carrying a value. get_valuations lists valuation entries newest first
with kind and the citation notes, scoped to accessible visible
accounts.

* feat(assistant): cache-stable system prompt with session context

The prompt interpolated currency mid-text and the date near the end, so
no two requests shared a cacheable prefix, and it told the model
nothing about the family: not one account name, not a single category.
Models opened most conversations blind, either wandering through tools
or answering without data.

The prompt is now STATIC_INSTRUCTIONS, a frozen constant that is
byte-identical for every request (providers discount an
exactly-repeated prefix; tool definitions are also stable now that
schemas carry no user data), followed by a trailing Session context
block holding everything volatile: date, date format, currency details,
an account roster with balances, and category names.

The static half gains a request-classification rule (CHAT / LOOKUP /
ANALYSIS), a reuse-what-you-have rule with an explicit re-fetch
carve-out, specific-tool preference, and the error/hint retry-once
rule that pairs with the tool soft-fail contract.

Context stays cheap by construction: the roster collapses to per-type
counts beyond 25 accounts, categories to a count beyond 60 names, and
both collapse whenever the configured context window is under 4096
(the self-hosted default is 2048), via the new Assistant::TokenBudget
helper. Intro chats are untouched.

* feat(assistant): raise tool-round cap to 8 with a no-tools grace turn; instructions-aware history budget

Five rounds was tight for a tool surface that now supports real
analysis chains, and hitting the cap raised ToolCallLimitError, which
surfaced to the user as a dead chat with an error banner. The default
is now eight rounds (env override unchanged), and on the final
permitted round the follow-up request offers no tools, so the model
must answer in text with whatever it gathered. The limit error remains
as a defensive backstop.

The generic-path history budget reserved a flat 256 tokens for a
system prompt that already estimates well past that; the trimmer now
budgets against the actual instructions when available.

LLM_MAX_RESPONSE_TOKENS was reserved in budget math but never sent to
the provider. It is now sent (max_tokens on chat completions,
max_output_tokens on the Responses API) only when explicitly
configured via ENV or a stored Setting; stock installs keep today's
uncapped behavior.

* test(evals): chat golden v2 exercising the real prompt and registry

The eval runner scored a fiction: hardcoded instructions and four fake
permissive tool schemas, so a prompt or registry regression could
sail through green. It now runs STATIC_INSTRUCTIONS plus a fixed
synthetic session context and builds definitions from
Assistant.function_classes against a reference user (classes whose
schema cannot build are skipped with a log line, never faked).

chat_golden_v2 adds routing scenarios the upgrade cares about: CHAT
classification must use no tools, aggregates route to
get_income_statement / get_balance_sheet rather than transaction
paging, and the new analytical tools are selected with sensible
params. The dataset header documents the harness's single-shot
limitation.

* docs(ai,mcp): current tool tables, responder loop, prompt structure, timeout math

Both docs listed 7 tools against a registry of 19, in three separate
drift-prone copies. mcp.md now carries the canonical tables (default +
preview); ai.md links to them from the MCP section, keeps one grouped
functions list for the architecture chapter, and replaces its stale
hardcoded registry snippet with a pointer to assistant.rb.

The architecture section gains the contracts contributors need when
adding a function: the responder loop (rounds vs calls, cap 8, the
no-tools grace turn) and the error/hint soft-failure convention, plus
the prompt's static/session-context split and its collapse gates.
Timeout guidance is recomputed for the new default cap.

* fix(assistant): address automated review findings

Codex and CodeRabbit findings on the initial push, all verified before
changing anything:

- AI time series rounded every value to two decimals, which turns
  0.001 BTC into 0.0; values now round to the currency's own precision
  (BTC 8, CLF 4, OMR 3).
- get_income_statement validated account_ids against all visible
  accounts, but totals_for excludes hidden, excluded-from-reports and
  tax-advantaged accounts, so those ids produced silent zeros. Ids now
  validate against income_statement.eligible_accounts and the soft
  failure explains eligibility.
- get_recurring_transactions computed totals from the displayed rows,
  so past the 200-row cap the value labeled a total was partial. Totals
  now aggregate over the full filtered scope in SQL, and the response
  carries total_results and a truncated flag. The upcoming_within_days
  window also starts at today, matching its documentation; overdue
  items appear in unwindowed calls.
- get_valuations silently dropped a malformed date filter and presented
  unfiltered data as filtered; malformed dates now return invalid_date.
- get_balance_sheet returned a generic failure for a reversed custom
  range because Period's own validation raises past the Date::Error
  rescue; it now returns the structured invalid_date error.
- get_insights documents that its family-wide scope matches the web
  feed exactly (InsightsController serves Current.family.insights to
  every member), so the tool exposes nothing the /insights page does
  not already show the same user.
- Tests: limit clamp proven against more insights than the cap,
  Setting fallbacks stubbed in the provider budget tests, currency
  precision and reversed-range regression tests added.

* refactor(assistant): apply reviewer nitpicks

- order declares type alongside its enum, matching sort_by
- page-size clamp deduplicated into the base class (MAX_PAGE_SIZE +
  shared resolved_page_size); dead per-tool copies removed
- get_accounts preloads balance rows only when the series is requested
- get_income_statement validates the bucket count before running any
  aggregation work

Deliberately unchanged: the balance sheet's monthly_history key. The
default response shape stays byte-compatible for existing MCP
consumers, and the nested series already states its interval.

* fix(assistant): second-round review findings on get_valuations

- A reversed date range (start after end) now returns the structured
  invalid_date error instead of presenting an empty result as filtered
  data, matching get_balance_sheet's handling.
- Page numbers are normalized before pagination: Pagy raises on zero,
  negative or non-numeric pages. The fix lands as a shared
  resolved_page helper on the base class and applies to every
  paginated tool (categories, tags, merchants, transactions, holdings,
  valuations), since all shared the same page-or-1 pattern; schemas
  declare minimum: 1.

* fix(assistant): round series amounts as BigDecimal before Float conversion

Converting to Float first can perturb the value at the requested
precision; round the exact decimal, then convert for JSON.

* fix(assistant): address maintainer review findings

- get_accounts no longer fails the whole listing when one account's
  start date lies beyond the requested period (start_date derives from
  the first entry, which can be future-dated); that account simply has
  no series. The unrescued Period.custom was reachable exactly there.
- The balances preload is gone: the series goes through
  Balance::ChartSeriesBuilder, which runs its own query keyed by
  account ids, so the eager-loaded rows were loaded and discarded.
- Provider::Openai#context_window now delegates to
  Assistant::TokenBudget, removing the duplicated ENV > Setting >
  default precedence so prompt assembly and the provider can never
  disagree about the window.

* fix(ai): final no-tools round uses tool_choice none instead of dropping tools

Anthropic rejects requests whose messages contain tool_use blocks when
no tools are defined, so re-requesting with an empty tool list made the
final-round grace die in a provider 400 on Anthropic models. The final
round now sends the real tool definitions with tool_choice none, which
both providers accept, and the model answers in prose as intended.

* fix(assistant): scope every income statement read to the requesting user

get_income_statement validated account_ids against the user-scoped statement
but computed every total from an unscoped one. IncomeStatement falls back to
Current.user, which is nil in the assistant job and the MCP endpoint, so the
unscoped reads dropped the included_in_finances_for filter and reported
family-wide totals next to ids that had been checked against a narrower set.

Route all reads through one memoized user-scoped statement, the idiom
get_balance_sheet already uses. Also lets the per-instance memoization in
IncomeStatement apply across the eligibility check and the totals.

Adds a regression test that fails without the change, plus a companion test
asserting eligibility and totals agree on scope. Guard the strictness walk
against an empty registry so it cannot silently assert nothing.
2026-08-21 21:58:47 +02:00
Sure Admin (bot) 5c18086089 docs: refresh Sure MCP and external AI setup (#2608)
* docs: explain self-hosted onboarding modes

* docs: refresh MCP and external AI docs

* docs: correct MCP auth and tool accuracy

* docs: address MCP review comments
2026-08-20 06:37:42 +02:00
Andrew B c9fbfd9f71 fix(chat): make the assistant response timeout configurable (#2910)
* fix(chat): make the assistant response timeout configurable (#2893)

Self-hosted users running a local model report the chat failing with
"assistant not available" after 90 seconds even though the model
generates a reply and tokens are billed.

Three timeouts are involved and only one was configurable:

  - OPENAI_REQUEST_TIMEOUT (60s) — already settable, not the blocker.
  - The browser watchdog in chat_controller.js (90s) — hardcoded, and
    this is what actually fires.
  - Chat::UNDELIVERED_RESPONSE_TIMEOUT (60s) — a constant, so raising
    the client value alone would not have helped.

The watchdog cannot be avoided by streaming here: custom
OpenAI-compatible providers route through generic_chat_response, which
forces synchronous calls, so nothing renders until the whole generation
finishes. Time-to-last-token has to beat the deadline.

Adds AI_RESPONSE_TIMEOUT (ENV > Setting > 90s default, floored at 30s),
exposed on the Self-Hosting settings page and passed to the Stimulus
controller at all three mount points — show, index and the sidebar in
the application layout, each of which declares data-controller="chat"
independently.

The server floor is derived from the same value but kept 10s below it.
report_timeout answers 200 whether or not it acted and the client only
retries on a non-ok response, so a floor at or above the client value
would let clock skew strand a pending bubble permanently.

Also guards AssistantMessage#append_text!. The watchdog runs in the web
process while the job holds its own copy of the message, so a job
finishing after the bubble was destroyed or demoted would silently
resurrect it alongside the error the user was already shown.

* fix(chat): let the watchdog retry when report_timeout declines

`report_timeout` answered 200 whether or not `handle_undelivered_response!`
acted. The Stimulus watchdog only stops retrying a URL once it sees a 2xx, so
a declined report was treated as final.

That stranded the bubble whenever the client's clock ran more than
SERVER_TIMEOUT_GRACE ahead of the server's: the watchdog posts at its own
timeout, the server sees a message younger than its floor and no-ops, and
nothing ever retries. The bubble spins forever with no error and no Retry.

Answering 409 instead lets the next 5s tick try again, so any amount of skew
costs retries rather than a stuck chat. The grace window stays as an
optimisation to keep those retries rare, not as the correctness mechanism.

* docs(chat): correct the AI_RESPONSE_TIMEOUT ordering guidance

The docs, locale string and examples all said to keep OPENAI_REQUEST_TIMEOUT at
or above AI_RESPONSE_TIMEOUT. That is backwards.

The two limits span different things. OPENAI_REQUEST_TIMEOUT bounds each HTTP
call to the model on its own; AI_RESPONSE_TIMEOUT covers the whole turn and its
clock starts when the message is queued, so it also absorbs Sidekiq queue time
and, for a tool-using turn, two model calls plus the tool run between them.

Keeping the chat timeout the larger of the two means a slow model surfaces the
specific HTTP timeout error rather than a generic "no response", and the job
stops instead of running on after the chat has given up. The shipped 60/90
defaults already had this ordering; only the guidance was wrong.

compose.example.ai.yml gets 300/660 so the Ollama example can actually complete
a tool-using turn.

* fix(chat): claim the pending bubble atomically before appending

append_text! read the row's status and then saved, leaving a window in which
the watchdog could demote the row to `failed` between the two. The late
content would then land on a bubble the user had already been told failed,
flipping it back to `complete`.

Replaces the read with a conditional UPDATE that only succeeds while the row is
still pending, so the check and the state change cannot be separated.

Uses a conditional UPDATE rather than with_lock because append_text! is called
once per chunk on the streaming path; a row lock and transaction per chunk would
be far more expensive. The claim only runs on the first append, since later ones
are no longer pending.

* test(chat): isolate AI_RESPONSE_TIMEOUT from the environment

Chat.response_timeout reads ENV ahead of Setting, so stubbing only the Setting
left the assertions at the mercy of the environment they run in. With
AI_RESPONSE_TIMEOUT=45 exported, five of these tests failed — the default,
floor and grace assertions were all silently measuring the env value.

Adds a with_setting_timeout helper that stubs the Setting and clears the
variable together, and switches the controller tests to stub
Chat.undelivered_response_timeout directly, since what they care about is the
resolved floor rather than how it was configured.

Both files now pass with or without AI_RESPONSE_TIMEOUT set.

* docs(chat): size AI_RESPONSE_TIMEOUT for chained tool calls

The guidance assumed a tool-using turn costs two model calls. #2767 landed
after this branch was opened and made tool calls iterative: `Assistant::Responder`
now loops until `iteration > max_tool_call_iterations`, so a turn runs to
1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS calls — six by default — with tool
execution in between. At the default 60s per-call timeout that is up to 360s of
model time against a 90s watchdog.

Streaming does not rescue this either. `emit(:output_text)` only fires for a
response that carries text, and tool-call-only rounds carry none, so the bubble
stays on "Thinking…" through every round regardless of provider.

Documents ASSISTANT_MAX_TOOL_CALL_ITERATIONS as the cheaper lever: dropping it to
2 halves the worst case instead of demanding a half-hour timeout, at the cost of
failing long tool chains earlier with a clear limit error. compose.example.ai.yml
now shows that combination rather than a timeout sized for six calls it never had.

* docs(chat): state the whole-turn timeout as a sum, not a maximum

The guidance said to keep AI_RESPONSE_TIMEOUT "above" or "the larger of"
OPENAI_REQUEST_TIMEOUT. That understates it: the watchdog covers the entire turn,
so the bound is

  (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) * OPENAI_REQUEST_TIMEOUT
    + tool execution + queue wait

Merely exceeding the per-call limit can still leave the chat reporting failure
while the worker keeps going.

One phrasing was outright wrong: "keep AI_RESPONSE_TIMEOUT the largest of the
three" compared a duration against ASSISTANT_MAX_TOOL_CALL_ITERATIONS, which is a
count, not seconds.

Resizes the examples against the formula — compose.example.ai.yml 1000 -> 1200 and
the Ollama doc example 600 -> 720, both now showing the arithmetic — and states
plainly that the 90s default is sized for typical cloud latency rather than the
worst-case bound, with the formula being what matters once per-call latency
approaches the timeout.

* docs(chat): list the AI settings fields and tag the formula fence

The Settings UI walkthrough listed three of the eight fields on the AI Provider
form. JSON Mode, the three Token Budget fields and the new Chat Response Timeout
were all missing, so the timeout was only discoverable from the troubleshooting
section. Rewrites the list to follow the form's own grouping and uses the labels
the form actually renders.

Also tags the whole-turn formula fence as `text` (markdownlint MD040).

* fix(compose): forward ASSISTANT_MAX_TOOL_CALL_ITERATIONS in standard compose

This file enumerates container environment explicitly — there is no env_file — so
a variable absent from the x-rails-env anchor never reaches web or worker.

The tool-call cap was only named in a comment here, while the docs added in
3360dbf5 tell operators to lower it to keep a turn inside AI_RESPONSE_TIMEOUT.
Following that advice on this compose file silently changed nothing: the app kept
the default of 5 while the timeout was sized for 3 calls, which lands back on the
"no response" error this branch exists to fix.

Left with an empty default so the app's own default governs, matching
OPENAI_MODEL and LLM_CONTEXT_WINDOW above. compose.example.ai.yml already
forwarded it.
2026-08-15 06:12:22 +02:00
Guillem Arias Fauste 344cf091e1 feat(auth): sign in with a passkey, without a password (#2911)
* feat(auth): sign in with a passkey, without a password

Passkeys could only ever replace the TOTP code: registration required 2FA
to already be on, and the WebAuthn ceremony was reachable only after
User.authenticate_by had succeeded. A registered passkey can now complete
sign-in on its own, from the login page.

The ceremony requests userVerification: "required", so the authenticator
has to confirm the person as well as the device. That makes a lone passkey
two independent factors, the same bar as the password plus TOTP flow it
replaces, which is why this path deliberately skips the TOTP step. A
credential that can only prove presence is rejected here and still works
as a second factor.

Sign-in is usernameless: no email is submitted, because the browser
returns the account handle with the assertion. Nothing on this path can be
probed to learn whether an account exists. Registration now asks for a
discoverable credential with residentKey: "preferred" so the key is
offered by the picker, while authenticators without a free resident-key
slot still register as a second factor.

Where conditional mediation is available, saved passkeys appear in the
email field's autofill menu; everywhere else the button covers it. The
automatic challenge request that conditional mediation makes on every page
load gets its own looser Rack::Attack budget, so ordinary page views can
no longer exhaust the limit that protects the MFA endpoints.

Set AUTH_PASSKEY_LOGIN_ENABLED=false to keep passkeys as a second factor
only. Passkey sign-in follows the same policy as local login, so it stays
closed to regular users when AUTH_LOCAL_LOGIN_ENABLED is false.

* refactor(auth): group the passkey button with the other sign-in methods

It sat directly under the password fields, so the forgot-password link
split it from the identical SSO buttons. It is an alternative to the
credential form rather than part of it.

* fix(auth): close the passkey challenge races and document the upgrade

Three review passes converged on the conditional-mediation flow. The
AbortController was created after `isConditionalMediationAvailable()`
resolved, so a button click or a Turbo disconnect landing in that window
found nothing to abort: the conditional task carried on, re-minted the
challenge, and the assertion the user was about to produce verified
against a challenge the server had already replaced.

It is created before the first await now, and held in a local, because
`abortConditionalMediation()` nulls the field. Checking that one signal
after each await covers both triggers, so no separate connected flag is
needed.

The same symptom had a second cause nobody flagged: `authenticate()` was
not re-entrant. A double-click minted a fresh challenge under an open
authenticator prompt and rejected a perfectly valid passkey, with no race
window at all — and it was live on the MFA step-up too, which shares the
method.

The conditional catch was silent for every failure, including a rejected
assertion the user had deliberately chosen from the autofill menu.
Splitting the try draws the line where it belongs: silence before the
user has been asked anything, feedback once they have picked a passkey.
Filtering on `error.name` cannot draw it, since `fetchOptions` and
`verifyCredential` both raise a plain Error.

Also documents the upgrade: passwordless is on by default and applies to
already-registered credentials, so a passkey added purely as a second
factor can now sign its owner in alone. Nothing in the schema marks a
credential discoverable — the authenticator decides — and the opt-out is
instance-wide.

The invitation test is a guard, not coverage for this change. The pending
token lives in the Rack session and `complete_sign_in` reads it right
after creating the session, so a `reset_session` dropped in between
strands the invitee in their own family, silently and with every existing
test still green.

* fix(auth): cancel the in-flight conditional options request

Aborting the conditional flow did not cancel its options request, because
`fetchOptions` never received the signal. A click landing while that POST
was in flight left it to finish, and its response could apply last.

The challenge rides in the session cookie, so "the server wrote it" only
counts if the Set-Cookie reaches the browser. Threading the signal means
an aborted request's response is discarded, which closes the window
without needing the server to hold two challenges open.

Also drops the absolute claim about which existing credentials gain
passwordless sign-in. `residentKey: "preferred"` is a request an
authenticator may decline, and nothing records what it decided, so the
honest statement is that password managers and platform authenticators
generally store discoverable credentials rather than always.
2026-08-12 20:35:13 +02:00
efb7cc3935 Tooling for the wealth + tax agent harness (#2848)
* Expose the Statement Vault to external agents over MCP

A user wants to manage patrimonial history — a document-backed record of a
family's wealth where every figure traces back to the statement it came from —
by pointing an external agent harness at Sure. That model belongs in the
harness, not in Sure: it needs numbered build deltas, golden tests and closed
periods that a mutable Postgres row cannot provide.

What Sure was missing was the seam. The Statement Vault already does most of
the work — original bytes retained, SHA-256 dedup, period detection, account
matching with a confidence score, reconciliation against ledger balances, and a
month-by-month coverage map — but it is reachable only from the web UI. An
agent could not archive a document, cite one, or check for gaps.

Adds five preview MCP tools over what already exists, plus a citation grammar
for values the agent writes:

- upload_account_statement, list_account_statements, get_account_statement,
  get_statement_coverage
- record_valuation, whose source citation is parsed rather than trusted:
  ["estimated: "] citation [" (grade: A|B|C)"]. An uncited or free-styled
  value is rejected at the write boundary instead of landing in the ledger
  looking authoritative.

link and reject are deliberately not exposed. Attaching a statement to an
account is the human's decision, and the vault UI is where it is made; the
agent reports the suggested match and stops there.

Assistant.function_classes now takes a user so preview tools stay out of the
default surface. They are hidden from tools/list and not callable by name
without the preference enabled, and the vault tools re-check the manager role
and per-account permissions, since MCP calls never pass through a controller.

Docs: the blueprint this implements, and a guide covering which side owns which
layer, the vocabulary map between the two, the monthly runbook, and the gaps
(non-user holders, non-statement documents, one value per date).

No migrations, no API endpoints, no UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn

* Address review feedback on the vault MCP tools

Two non-blocking items from the review pass:

Document why get_statement_coverage reads through accessible_by rather than
writable_by. It reports which documents exist and writes nothing, so read
access is the right bar — and tightening it would hide coverage gaps from
people who can already see the figures those gaps sit behind. The comment
exists so a future refactor doesn't "fix" it.

Close the acknowledged verification gap with tests rather than a one-off
manual check. The review noted that nothing proved a real vault payload
serializes cleanly out through tools/call — vault responses are richer than
the other tools' output, with nested account hashes, decimal balances, dates
and a compacted hash. Two integration tests now drive the real /mcp endpoint
end to end against a real AccountStatement: one listing it, one uploading
bytes and reading back the SHA-256. Permanent regression coverage instead of
a smoke test someone has to remember to repeat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn

* docs(llm-guides): replace patrimonial blueprint with its final revision

Swap the embedded early draft for the authoritative final revision of the
wealth + tax modelling blueprint (MIT © 2026 diegomarino):

- rename the domain vocabulary: patrimonial -> wealth, fiscal -> tax
  (tax_data/, the tax layer, tax_runner)
- add §9.5 (the intel file: shape, generation, and the capture loop)
- tighten worked examples down to placeholders
- add the MIT header; keep the in-repo NOTE block (adapted to the new
  vocabulary) and the filename untouched so cross-links don't break

* docs(llm-guides): align agent-harness guide with blueprint + fix reconcile semantics

Follow the blueprint rename (patrimonial -> wealth, fiscal -> tax,
fiscal_data/ -> tax_data/, "Phase 7 (fiscal layer)" -> "(the tax layer)")
so the two docs stop disagreeing on vocabulary.

Correct the reconciliation mapping, which conflated two different invariants:

- blueprint reconcile-or-abort (§7 pass 3) is parse-integrity (parsed parts
  == the document's own printed total); Sure's reconciliation_checks is
  ledger agreement (statement balances vs the ledger). Sure has no
  parse-integrity check and never aborts.
- opening_balance / closing_balance are user-entered, not auto-extracted, so
  over MCP reconciliation is "unavailable" until a human fills them.
- tolerance differs: blueprint 1.00/account-period vs Sure's fixed 0.01.

State in the ownership table, the invariants section, the vocabulary map and
the monthly runbook that parse-integrity and the abort belong to the harness
extractor.

* Correct the vault tools' reconciliation claims and citation parsing

Review findings from @diegomarino, all verified against the code before
changing anything.

The reconciliation claim was the serious one. get_account_statement told
agents the checks were "the trustworthy part" and returned "the balances read
off it" — but nothing reads balances off a document. MetadataDetector never
touches them and create_from_prepared_upload! never sets them; they are
user-editable fields in the Statement Vault UI. So a statement archived over
MCP always came back with an empty check list, which an agent could easily
read as "the document agrees with the ledger" when it means "nobody has
entered the figures". The description now says so, and the payload carries a
reconciliation_note spelling it out for anything reading only the JSON. Also
noted that these checks are ledger agreement, not parse integrity: nothing
here verifies a document's parts sum to its printed total.

Provenance::Citation had two patterns disagreeing about spacing. GRADE_SUFFIX
allowed "(grade:A)" but FORMAT required exactly one space, so that citation
passed the pre-check and then parsed as ungraded with the grade swallowed into
the text — silently discarding the reliability the caller supplied, which is
the one thing this parser exists to prevent.

list_account_statements downcases content_sha256 before querying. The column
is constrained to lowercase hex, so uppercase input could never match, and an
agent would read the empty result as "not archived" and upload a duplicate.
Its period filters are renamed overlapping_from / overlapping_until, since
they match on overlap and the old names claimed otherwise to anyone reading
the schema without the descriptions. has_more now explains that there is no
cursor and the way forward is a bigger limit or narrower filters.

record_valuation no longer overwrites the entry's notes. Re-recording a date
would destroy a note a person had written there. Nothing is removed now: an
identical citation is a no-op, a changed one is appended, and the trail of
what was cited when survives. Detecting "did this tool write that line?" is
not possible — almost any prose parses as a valid ungraded citation — so the
code does not guess.

Minor: accept urlsafe base64 on upload, and explain in the code why
record_valuation checks the account ACL rather than the vault manager role, so
nobody "tightens" it into the wrong permission later.

Tests cover each: the grade-spacing cases both ways, uppercase SHA lookup,
overlap window boundaries, note preservation and no-stacking, the unavailable
reconciliation note appearing and disappearing, and — per the review — that
the download URL's signed id actually expires, rather than trusting the
description's claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn

* Repair a bad merge in the MCP controller test

The merge of main spliced the incoming `tools/call executes
update_transaction` test into the middle of the upload round-trip test,
before its closing `end`. That left the file one `end` short, so it did
not parse — taking out both `ci / lint` (Lint/Syntax) and `ci / test_unit`
(the whole file failed to load).

Restores the missing `end`. Both tests are kept as their authors wrote
them; nothing else changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn

* docs: use wealth history wording (#2885)

* Stop the vault tools promising verification they don't perform

Three findings from the automated review passes, all confirmed against the
code before changing anything.

The download URL was dead on arrival for the caller it was built for. Sure
serves stored files through Active Storage controllers that
config/initializers/active_storage_authorization.rb gates on
`viewable_by?(Current.user)` — a signed-in browser session. An MCP client has
a bearer token and no session, so following the URL would have redirected to
sign-in. Removed it rather than leaving a link that cannot work, and the
description now points at search_family_files or the vault UI.

Coverage called a month `covered` when a document merely existed. An
unreconciled statement is not mismatched, so it took the `covered` branch, and
the payload carried nothing to correct the reading — the same "advertised
verification that never happened" bug fixed last round in
get_account_statement, in a second place. Months now carry their own
reconciliation_status, and the description says covered means presence, not
agreement.

Listing filtered visibility after limiting. Beyond underfilling a page, with
no cursor and a 100-row cap an accessible statement behind enough newer
invisible ones was unreachable. Visibility now lives in the query, mirroring
viewable_by? for a statement manager.

Also: rescue unexpected upload failures into a tool error instead of a raw
exception string, derive the documented size limit from MAX_FILE_SIZE, list
every coverage status in mcp.md, and cover the failed-reconciliation and
base64-normalisation branches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn

* Keep storage exception detail out of the MCP response

The upload_failed message interpolated the exception text, which crosses out
to an external agent. A storage failure can carry bucket names, object keys,
paths or request details, so the agent now gets a fixed message and the
exception stays in the server log. The test asserts the absence of detail
rather than pinning the leaked string into the contract.

Also fixes a test that did not test what it claimed: the urlsafe-base64 case
used a fixture encoding to plain base64, so it exercised the padding branch
and never the "-_" translation. It now uses content whose encoding contains
both characters and asserts that up front.

Renames "rejects content that decodes to zero bytes" to "rejects blank
content", which is what it actually covers — Base64.strict_encode64("") is
"", which is blank and returns before the decoder runs, so invalid_content is
correct and empty_file is not reachable from this path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn

* docs: align wealth blueprint review feedback

* Correct the harness runbook: parse before publishing

The guide told an implementer to archive each document to Sure first and
work from there. That strands them. Sure never returns a document's bytes
over MCP — Active Storage serves stored files only to a signed-in browser
session — and there is no text fallback either, because statements archived
through upload_account_statement never enter the vector store, so
search_family_files cannot see them. A statement in Sure is metadata to an
agent and nothing more.

That blocks exactly three blueprint steps, all of them operating on bank and
broker statements: the extractors, the parts-vs-printed-total check, and the
glyph decoder. Everything else it parses — tax returns, capital accounts,
annual accounts — the harness already holds locally.

So the order inverts: the harness ingests into its own vault, extracts there
with the whole file in reach, and publishes to Sure afterwards. This restores
principle 8 rather than bending it — the recurring pipeline reads from the
canonical store, and treating Sure as canonical forced a re-fetch the
architecture never sanctioned. Both sides hash the same bytes, so the SHA-256
verifies Sure holds the identical document without moving it.

Writes down the two consequences: a statement uploaded straight into Sure's
UI can be known but never parsed (reliability C or PENDING until a copy
reaches the harness), and neither vault backs up the other.

Also drops a stale tools-table row still advertising the 15-minute download
URL removed earlier, corrects get_account_statement's description where it
suggested search_family_files as a fallback it cannot be, and disambiguates
"the vault" in the MCP tool table, which is what misled me in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: diegomarino <diegomarino@users.noreply.github.com>
Co-authored-by: Sure Admin (bot) <sure-admin@splashblot.com>
2026-08-04 23:33:01 +02:00
AtlasandJuan José Mata 53a87a7645 fix(mcp): assign OAuth clients read_write scope (#2884)
* fix(mcp): assign OAuth clients read_write scope

* fix(mcp): default registered OAuth scope

---------

Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-04 23:10:39 +02:00
Oscar 0fa1fbd650 add redbark setup guide to hosting docs (#2817)
- new docs/hosting/redbark.md covering account setup, api keys, linking and sync behaviour
- listed redbark in the onboarding guide's provider integrations
2026-07-28 06:42:57 +02:00
Juan José Mata c6eb7cdeed Revert "Refactor application workflows and update test coverage"
This reverts commit 565e049f89.
2026-07-23 13:39:46 -07:00
Juan José Mata 565e049f89 Refactor application workflows and update test coverage 2026-07-21 22:01:59 -07:00
Sure Admin (bot)coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Juan José Mata
f62c805c69 docs: clarify local LLM context window tuning (#2661)
* docs(ai): document local LLM context window tuning

* Update compose.example.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

---------

Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-17 07:10:29 +02:00
Guillem Arias Fauste e699f5272b feat(sidekiq): mount Web UI in production behind super-admin sessions (#2683)
/sidekiq was mounted `unless Rails.env.production?`, and the Docker
image bakes RAILS_ENV=production — so no self-hosted or managed
deployment has ever had queue tooling, while the production basic-auth
block (with default "sure"/"sure" credentials) was dead code. Worse,
any custom non-production env (e.g. staging) got the dashboard with no
authentication at all.

Now:

- Development keeps the open mount for convenience.
- Everywhere else the route only exists for a signed-in super admin,
  via a routing constraint that resolves the signed session cookie the
  same way Authentication#find_session_by_cookie does. The session's
  user is always the true user (impersonation is resolved at the
  Current level and impersonating a super admin is forbidden), and
  sessions are only created after MFA verification, so neither can
  bypass it. Fails closed — errors mean 404.
- The "sure"/"sure" default credentials are deleted. Basic auth now
  activates only when BOTH SIDEKIQ_WEB_USERNAME and
  SIDEKIQ_WEB_PASSWORD are explicitly set, as an optional second
  layer on top of the constraint.
- Documented in .env.example and docs/hosting/docker.md, including
  warnings that the dashboard is break-glass tooling: never manually
  retry SimplefinConnectionUpdateJob (single-use token), and deleting
  jobs does not update the corresponding Sure records.

The super-admin bar's Jobs link is feature-detected via
sidekiq_web_available? and lights up automatically now that the route
exists in production.
2026-07-17 07:00:27 +02:00
Sure Admin (bot) 9f253f21df docs: explain self-hosted onboarding modes (#2533) 2026-06-30 21:09:25 +02:00
Josh 60d9a70aff Refresh Pipelock integration for v2.8 receipts (#2406)
* chore(pipelock): refresh integration for v2.8 receipts

* Clarify Pipelock receipt key mounts
2026-06-19 17:16:38 +02:00
JoshandJuan José Mata ca895416a4 chore(helm): bump pipelock to 2.5.0 and surface 2.5 config (#1913)
* chore(helm): bump pipelock to 2.5.0 and surface 2.5 config

Bumps pipelock.image.tag from 2.2.0 to 2.5.0 and exposes the most
relevant 2.5 features as structured Helm values:

- pipelock.requestBodyScanning: scan outbound bodies and sensitive
  headers for prompt-injection and DLP payloads. Disabled by default;
  roll out with action=warn before flipping to block.
- pipelock.healthWatchdog: structured config for the wedge-detection
  watchdog with an exposeSubsystems toggle for /health detail.
- pipelock.mcpToolPolicy.rules: structured values for rendering
  mcp_tool_policy.rules including redirect-profile references.

Also fixes a latent config-validation regression: pipelock 2.x rejects
an enabled mcp_tool_policy with no rules, but the chart previously
defaulted to enabled=true with an empty rules list, which hard-fails
'pipelock check'. The default is now enabled=false; operators must
explicitly enable and provide at least one rule.

Refreshes README, CHANGELOG, docs/hosting/pipelock.md, docs/hosting/ai.md,
compose example pin comment, and pipelock.example.yaml to call out 2.5
highlights (Audit Packet v0 verifiers, SPIFFE-strict envelopes, scanner
attribution on MCP block receipts, pipelock doctor). Also fixes a stale
docs/hosting/mcp.md reference to the removed compose.example.pipelock.yml.

* chore(helm): fail helm template when mcp_tool_policy enabled with no rules

Adds a guard in asserts.tpl so an operator who sets
pipelock.mcpToolPolicy.enabled=true without populating
pipelock.mcpToolPolicy.rules gets a clear render-time error instead
of a container crash-loop with the pipelock validation message.

Per CodeRabbit feedback on #1913.

* Versions

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-24 13:50:44 +02:00
ghost 911aa34ba9 feat(auth): add WebAuthn MFA credentials (#1628)
* feat(auth): add WebAuthn MFA credentials

* fix(auth): harden WebAuthn MFA review paths

* fix(auth): polish WebAuthn error handling

* fix(auth): handle duplicate WebAuthn credential races

* fix(auth): permit WebAuthn credential params

* fix(auth): trim WebAuthn registration controller cleanup

* fix(auth): tighten WebAuthn MFA handling

* fix(auth): pin WebAuthn relying party config
2026-05-03 22:13:28 +02:00
LPW b457514c31 chore(pipelock): bump chart default to v2.2.0, add CI scan badge (#1494)
- Helm chart default pipelock.image.tag bumped from 2.0.0 to 2.2.0
  (three minor releases behind latest)
- README: pipelock CI scan status badge added to the existing badge row
- charts/sure/README.md, docs/hosting/pipelock.md, pipelock.example.yaml:
  refreshed feature notes to reference the upstream changelog rather than
  pinning to a single version
- compose.example.ai.yml: pin example comment bumped to :2.2.0
- Workflow pin (@v2) unchanged — floating major tag picks up 2.2.x
2026-04-18 09:32:23 +02:00
soky srmandJuan José Mata 90b1308866 Ipv6 support (#1437)
* Ipv6 support

* Proper fix for containers, dev and local

* Edits similar to non-AI compose file

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-04-13 13:44:37 +02:00
Copilotjjmatacopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1527611239 Default production SSO provider source to YAML to avoid boot-time schema errors (#1278)
* Initial plan

* Default production SSO provider source to YAML

Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com>
Agent-Logs-Url: https://github.com/we-promise/sure/sessions/d3a36ca8-e936-4687-a466-9b4c93c19150

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com>
2026-03-25 15:08:36 +01:00
LPWandJuan José Mata 1ddc427fd5 chore(helm): bump pipelock to v2.0.0 with trusted domains and redirect profiles (#1266)
* chore(helm): bump pipelock to v2.0.0 with trusted domains and redirect profiles

- Bump pipelock image tag from 1.5.0 to 2.0.0
- Add first-class Helm values for trustedDomains and mcpToolPolicy.redirectProfiles
- Update CI GitHub Action from @v1 to @v2
- Update compose example, config reference, and docs with v2.0 features

* Releasing this today in `alpha` form

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-03-24 09:30:54 +01:00
LPWandJuan José Mata e43a8f295d Bump pipelock image from 0.3.2 to 1.5.0 (#1238)
* Bump pipelock image from 0.3.2 to 1.5.0

* Releasing via `alpha`

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-03-21 15:32:07 +01:00
Dreamandsokiee 6d22514c01 feat(vector-store): Implement pgvector adapter for self-hosted RAG (#1211)
* Add conditional migration for vector_store_chunks table

Creates the pgvector-backed chunks table when VECTOR_STORE_PROVIDER=pgvector.
Enables the vector extension, adds store_id/file_id indexes, and uses
vector(1024) column type for embeddings.

* Add VectorStore::Embeddable concern for text extraction and embedding

Shared concern providing extract_text (PDF via pdf-reader, plain-text as-is),
paragraph-boundary chunking (~2000 chars, ~200 overlap), and embed/embed_batch
via OpenAI-compatible /v1/embeddings endpoint using Faraday. Configurable via
EMBEDDING_MODEL, EMBEDDING_URI_BASE, with fallback to OPENAI_* env vars.

* Implement VectorStore::Pgvector adapter with raw SQL

Replaces the stub with a full implementation using
ActiveRecord::Base.connection with parameterized binds. Supports
create_store, delete_store, upload_file (extract+chunk+embed+insert),
remove_file, and cosine-similarity search via the <=> operator.

* Add registry test for pgvector adapter selection

* Configure pgvector in compose.example.ai.yml

Switch db image to pgvector/pgvector:pg16, add VECTOR_STORE_PROVIDER,
EMBEDDING_MODEL, and EMBEDDING_DIMENSIONS env vars, and include
nomic-embed-text in Ollama's pre-loaded models.

* Update pgvector docs from scaffolded to ready

Document env vars, embedding model setup, pgvector Docker image
requirement, and Ollama pull instructions.

* Address PR review feedback

- Migration: remove env guard, use pgvector_available? check so it runs
  on plain Postgres (CI) but creates the table on pgvector-capable servers.
  Add NOT NULL constraints on content/embedding/metadata, unique index on
  (store_id, file_id, chunk_index).
- Pgvector adapter: wrap chunk inserts in a DB transaction to prevent
  partial file writes. Override supported_extensions to match formats
  that extract_text can actually parse.
- Embeddable: add hard_split fallback for paragraphs exceeding CHUNK_SIZE
  to avoid overflowing embedding model token limits.

* Bump schema version to include vector_store_chunks migration

CI uses db:schema:load which checks the version — without this bump,
the migration is detected as pending and tests fail to start.

* Update 20260316120000_create_vector_store_chunks.rb

---------

Co-authored-by: sokiee <sokysrm@gmail.com>
2026-03-20 17:01:31 +01:00
Andrei Onelaskmanu[bot] <192355599+askmanu[bot]@users.noreply.github.com>Juan José Mata
a0b1029ba9 Documentation for review AI Assistant features, MCP and API additions (#1168)
* Create MCP server endpoint documentation

* Add Assistant Architecture section to AI documentation

* Add Users API documentation for account reset and delete endpoints

* Document Pipelock CI security scanning in contributing guide

* fix: correct scope and error codes in Users API documentation

* Exclude `docs/hosting/ai.md` from Pipelock scan

---------

Co-authored-by: askmanu[bot] <192355599+askmanu[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-03-16 18:24:28 +01:00
LPW ca8f04040f Expand AI docs: external assistant, MCP, architecture, troubleshooting (#1115)
* Expand AI docs: architecture, MCP, external assistant setup, troubleshooting

- Add architecture overview explaining two independent AI pipelines
  (chat assistant vs auto-categorization)
- Document MCP callback endpoint (JSON-RPC 2.0, auth, available tools)
- Add OpenClaw gateway configuration example
- Add Kubernetes network policy guidance (targetPort vs servicePort)
- Add Pipelock notes (mcpToolPolicy, NO_PROXY behavior)
- Add troubleshooting for "Failed to generate response" with external assistant
- Fix stale function list (4 tools -> 7)
- Fix incorrect env-vs-UI precedence statement
- Fix em-dashes in existing content

* Fix troubleshooting curl to use pod env vars

Use sh -c so $EXTERNAL_ASSISTANT_TOKEN and $EXTERNAL_ASSISTANT_URL
expand inside the pod, not on the local shell.
2026-03-04 11:26:43 +01:00
a53a131c46 Add Pipelock operational templates, docs, and config hardening (#1102)
* feat(helm): add Pipelock ConfigMap, scanning config, and consolidate compose

- Add ConfigMap template rendering DLP, response scanning, MCP input/tool
  scanning, and forward proxy settings from values
- Mount ConfigMap as /etc/pipelock/pipelock.yaml volume in deployment
- Add checksum/config annotation for automatic pod restart on config change
- Gate HTTPS_PROXY/HTTP_PROXY env injection on forwardProxy.enabled (skip
  in MCP-only mode)
- Use hasKey for all boolean values to prevent Helm default swallowing false
- Single source of truth for ports (forwardProxy.port/mcpProxy.port)
- Pipelock-specific imagePullSecrets with fallback to app secrets
- Merge standalone compose.example.pipelock.yml into compose.example.ai.yml
- Add pipelock.example.yaml for Docker Compose users
- Add exclude-paths to CI workflow for locale file false positives

* Add external assistant support (OpenAI-compatible SSE proxy)

Allow self-hosted instances to delegate chat to an external AI agent
via an OpenAI-compatible streaming endpoint. Configurable per-family
through Settings UI or ASSISTANT_TYPE env override.

- Assistant::External::Client: SSE streaming HTTP client (no new gems)
- Settings UI with type selector, env lock indicator, config status
- Helm chart and Docker Compose env var support
- 45 tests covering client, config, routing, controller, integration

* Add session key routing, email allowlist, and config plumbing

Route to the actual OpenClaw session via x-openclaw-session-key header
instead of creating isolated sessions. Gate external assistant access
behind an email allowlist (EXTERNAL_ASSISTANT_ALLOWED_EMAILS env var).
Plumb session_key and allowedEmails through Helm chart, compose, and
env template.

* Add HTTPS_PROXY support to External::Client for Pipelock integration

Net::HTTP does not auto-read HTTPS_PROXY/HTTP_PROXY env vars (unlike
Faraday). Explicitly resolve proxy from environment in build_http so
outbound traffic to the external assistant routes through Pipelock's
forward proxy when enabled. Respects NO_PROXY for internal hosts.

* Add UI fields for external assistant config (Setting-backed with env fallback)

Follow the same pattern as OpenAI settings: database-backed Setting
fields with env var defaults. Self-hosters can now configure the
external assistant URL, token, and agent ID from the browser
(Settings > Self-Hosting > AI Assistant) instead of requiring env vars.
Fields disable when the corresponding env var is set.

* Improve external assistant UI labels and add help text

Change placeholder to generic OpenAI-compatible URL pattern. Add help
text under each field explaining where the values come from: URL from
agent provider, token for authentication, agent ID for multi-agent
routing.

* Add external assistant docs and fix URL help text

Add External AI Assistant section to docs/hosting/ai.md covering setup
(UI and env vars), how it works, Pipelock security scanning, access
control, and Docker Compose example. Drop "chat completions" jargon
from URL help text.

* Harden external assistant: retry logic, disconnect UI, error handling, and test coverage

- Add retry with backoff for transient network errors (no retry after streaming starts)
- Add disconnect button with confirmation modal in self-hosting settings
- Narrow rescue scope with fallback logging for unexpected errors
- Safe cleanup of partial responses on stream interruption
- Gate ai_available? on family assistant_type instead of OR-ing all providers
- Truncate conversation history to last 20 messages
- Proxy-aware HTTP client with NO_PROXY support
- Sanitize protocol to use generic headers (X-Agent-Id, X-Session-Key)
- Full test coverage for streaming, retries, proxy routing, config, and disconnect

* Exclude external assistant client from Pipelock scan-diff

False positive: `@token` instance variable flagged as "Credential in URL".
Temporary workaround until Pipelock supports inline suppression.

* Address review feedback: NO_PROXY boundary fix, SSE done flag, design tokens

- Fix NO_PROXY matching to require domain boundary (exact match or .suffix),
  case-insensitive. Prevents badexample.com matching example.com.
- Add done flag to SSE streaming so read_body stops after [DONE]
- Move MAX_CONVERSATION_MESSAGES to class level
- Use bg-success/bg-destructive design tokens for status indicators
- Add rationale comment for pipelock scan exclusion
- Update docs last-updated date

* Address second round of review feedback

- Allowlist email comparison is now case-insensitive and nil-safe
- Cap SSE buffer at 1 MB to prevent memory blowup from malformed streams
- Don't expose upstream HTTP response body in user-facing errors (log it instead)
- Fix frozen string warning on buffer initialization
- Fix "builtin" typo in docs (should be "built-in")

* Protect completed responses from cleanup, sanitize error messages

- Don't destroy a fully streamed assistant message if post-stream
  metadata update fails (only cleanup partial responses)
- Log raw connection/HTTP errors internally, show generic messages
  to users to avoid leaking network/proxy details
- Update test assertions for new error message wording

* Fix SSE content guard and NO_PROXY test correctness

Use nil check instead of present? for SSE delta content to preserve
whitespace-only chunks (newlines, spaces) that can occur in code output.

Fix NO_PROXY test to use HTTP_PROXY matching the http:// client URL so
the proxy resolution and NO_PROXY bypass logic are actually exercised.

* Forward proxy credentials to Net::HTTP

Pass proxy_uri.user and proxy_uri.password to Net::HTTP.new so
authenticated proxies (http://user:pass@host:port) work correctly.
Without this, credentials parsed from the proxy URL were silently
dropped. Nil values are safe as positional args when no creds exist.

* Update pipelock integration to v0.3.1 with full scanning config

Bump Helm image tag from 0.2.7 to 0.3.1. Add missing security
sections to both the Helm ConfigMap and compose example config:
mcp_tool_policy, mcp_session_binding, and tool_chain_detection.
These protect the /mcp endpoint against tool injection, session
hijacking, and multi-step exfiltration chains.

Add version and mode fields to config files. Enable include_defaults
for DLP and response scanning to merge user patterns with the 35
built-in patterns. Remove redundant --mode CLI flag from the Helm
deployment template since mode is now in the config file.

* Pipelock Helm hardening + docs for external assistant and pipelock

Helm templates:
- ServiceMonitor for Prometheus scraping on /metrics (proxy port)
- Ingress template for MCP reverse proxy (external AI agent access)
- PodDisruptionBudget with minAvailable/maxUnavailable mutual exclusion
- topologySpreadConstraints on Deployment
- Structured logging config (format, output, include_allowed/blocked)
- extraConfig escape hatch for additional pipelock.yaml sections
- requireForExternalAssistant guard (fails when assistant enabled without pipelock)
- Component label on Service metadata for ServiceMonitor targeting
- NOTES.txt pipelock section with health, access, security, metrics info
- Bump pipelock image tag 0.3.1 -> 0.3.2
- Fix: rename _asserts.tpl -> asserts.tpl (Helm skipped _ prefixed file)

Documentation:
- Helm chart README: full Pipelock section
- docs/hosting/pipelock.md: dedicated hosting guide (Docker + Kubernetes)
- docs/hosting/docker.md: AI features section (external assistant, pipelock)
- .env.example: external assistant and MCP env vars

Infra:
- Chart.lock pinning dependency versions
- .gitignore for vendored subchart tarballs

* Fix bot comments: quote ingress host, fix sidecar wording, add code block lang

* Fail fast when pipelock ingress enabled with empty hosts

* Fail fast when pipelock ingress host has empty paths

* Messed up the conflict merge

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-03-03 16:32:35 +01:00
LPW 84bfe5b7ab Add external AI assistant with Pipelock security proxy (#1069)
* feat(helm): add Pipelock ConfigMap, scanning config, and consolidate compose

- Add ConfigMap template rendering DLP, response scanning, MCP input/tool
  scanning, and forward proxy settings from values
- Mount ConfigMap as /etc/pipelock/pipelock.yaml volume in deployment
- Add checksum/config annotation for automatic pod restart on config change
- Gate HTTPS_PROXY/HTTP_PROXY env injection on forwardProxy.enabled (skip
  in MCP-only mode)
- Use hasKey for all boolean values to prevent Helm default swallowing false
- Single source of truth for ports (forwardProxy.port/mcpProxy.port)
- Pipelock-specific imagePullSecrets with fallback to app secrets
- Merge standalone compose.example.pipelock.yml into compose.example.ai.yml
- Add pipelock.example.yaml for Docker Compose users
- Add exclude-paths to CI workflow for locale file false positives

* Add external assistant support (OpenAI-compatible SSE proxy)

Allow self-hosted instances to delegate chat to an external AI agent
via an OpenAI-compatible streaming endpoint. Configurable per-family
through Settings UI or ASSISTANT_TYPE env override.

- Assistant::External::Client: SSE streaming HTTP client (no new gems)
- Settings UI with type selector, env lock indicator, config status
- Helm chart and Docker Compose env var support
- 45 tests covering client, config, routing, controller, integration

* Add session key routing, email allowlist, and config plumbing

Route to the actual OpenClaw session via x-openclaw-session-key header
instead of creating isolated sessions. Gate external assistant access
behind an email allowlist (EXTERNAL_ASSISTANT_ALLOWED_EMAILS env var).
Plumb session_key and allowedEmails through Helm chart, compose, and
env template.

* Add HTTPS_PROXY support to External::Client for Pipelock integration

Net::HTTP does not auto-read HTTPS_PROXY/HTTP_PROXY env vars (unlike
Faraday). Explicitly resolve proxy from environment in build_http so
outbound traffic to the external assistant routes through Pipelock's
forward proxy when enabled. Respects NO_PROXY for internal hosts.

* Add UI fields for external assistant config (Setting-backed with env fallback)

Follow the same pattern as OpenAI settings: database-backed Setting
fields with env var defaults. Self-hosters can now configure the
external assistant URL, token, and agent ID from the browser
(Settings > Self-Hosting > AI Assistant) instead of requiring env vars.
Fields disable when the corresponding env var is set.

* Improve external assistant UI labels and add help text

Change placeholder to generic OpenAI-compatible URL pattern. Add help
text under each field explaining where the values come from: URL from
agent provider, token for authentication, agent ID for multi-agent
routing.

* Add external assistant docs and fix URL help text

Add External AI Assistant section to docs/hosting/ai.md covering setup
(UI and env vars), how it works, Pipelock security scanning, access
control, and Docker Compose example. Drop "chat completions" jargon
from URL help text.

* Harden external assistant: retry logic, disconnect UI, error handling, and test coverage

- Add retry with backoff for transient network errors (no retry after streaming starts)
- Add disconnect button with confirmation modal in self-hosting settings
- Narrow rescue scope with fallback logging for unexpected errors
- Safe cleanup of partial responses on stream interruption
- Gate ai_available? on family assistant_type instead of OR-ing all providers
- Truncate conversation history to last 20 messages
- Proxy-aware HTTP client with NO_PROXY support
- Sanitize protocol to use generic headers (X-Agent-Id, X-Session-Key)
- Full test coverage for streaming, retries, proxy routing, config, and disconnect

* Exclude external assistant client from Pipelock scan-diff

False positive: `@token` instance variable flagged as "Credential in URL".
Temporary workaround until Pipelock supports inline suppression.

* Address review feedback: NO_PROXY boundary fix, SSE done flag, design tokens

- Fix NO_PROXY matching to require domain boundary (exact match or .suffix),
  case-insensitive. Prevents badexample.com matching example.com.
- Add done flag to SSE streaming so read_body stops after [DONE]
- Move MAX_CONVERSATION_MESSAGES to class level
- Use bg-success/bg-destructive design tokens for status indicators
- Add rationale comment for pipelock scan exclusion
- Update docs last-updated date

* Address second round of review feedback

- Allowlist email comparison is now case-insensitive and nil-safe
- Cap SSE buffer at 1 MB to prevent memory blowup from malformed streams
- Don't expose upstream HTTP response body in user-facing errors (log it instead)
- Fix frozen string warning on buffer initialization
- Fix "builtin" typo in docs (should be "built-in")

* Protect completed responses from cleanup, sanitize error messages

- Don't destroy a fully streamed assistant message if post-stream
  metadata update fails (only cleanup partial responses)
- Log raw connection/HTTP errors internally, show generic messages
  to users to avoid leaking network/proxy details
- Update test assertions for new error message wording

* Fix SSE content guard and NO_PROXY test correctness

Use nil check instead of present? for SSE delta content to preserve
whitespace-only chunks (newlines, spaces) that can occur in code output.

Fix NO_PROXY test to use HTTP_PROXY matching the http:// client URL so
the proxy resolution and NO_PROXY bypass logic are actually exercised.

* Forward proxy credentials to Net::HTTP

Pass proxy_uri.user and proxy_uri.password to Net::HTTP.new so
authenticated proxies (http://user:pass@host:port) work correctly.
Without this, credentials parsed from the proxy URL were silently
dropped. Nil values are safe as positional args when no creds exist.

* Update pipelock integration to v0.3.1 with full scanning config

Bump Helm image tag from 0.2.7 to 0.3.1. Add missing security
sections to both the Helm ConfigMap and compose example config:
mcp_tool_policy, mcp_session_binding, and tool_chain_detection.
These protect the /mcp endpoint against tool injection, session
hijacking, and multi-step exfiltration chains.

Add version and mode fields to config files. Enable include_defaults
for DLP and response scanning to merge user patterns with the 35
built-in patterns. Remove redundant --mode CLI flag from the Helm
deployment template since mode is now in the config file.
2026-03-03 15:47:51 +01:00
Juan José Mata 4e4ca916a1 Update backend table with status and requirements
Clarify status of non-OpenAI vector store

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2026-02-11 15:59:12 +01:00
Juan José Matacoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Claude
9e57954a99 Add Family vector search function call / support for document vault (#961)
* Add SearchFamilyImportedFiles assistant function with vector store support

Implement per-Family document search using OpenAI vector stores, allowing
the AI assistant to search through uploaded financial documents (tax returns,
statements, contracts, etc.). The architecture is modular with a provider-
agnostic VectorStoreConcept interface so other RAG backends can be added.

Key components:
- Assistant::Function::SearchFamilyImportedFiles - tool callable from any LLM
- Provider::VectorStoreConcept - abstract vector store interface
- Provider::Openai vector store methods (create, upload, search, delete)
- Family::VectorSearchable concern with document management
- FamilyDocument model for tracking uploaded files
- Migration adding vector_store_id to families and family_documents table

https://claude.ai/code/session_01TSkKc7a9Yu2ugm1RvSf4dh

* Extract VectorStore adapter layer for swappable backends

Replace the Provider::VectorStoreConcept mixin with a standalone adapter
architecture under VectorStore::. This cleanly separates vector store
concerns from the LLM provider and makes it trivial to swap backends.

Components:
- VectorStore::Base — abstract interface (create/delete/upload/remove/search)
- VectorStore::Openai — uses ruby-openai gem's native vector_stores.search
- VectorStore::Pgvector — skeleton for local pgvector + embedding model
- VectorStore::Qdrant — skeleton for Qdrant vector DB
- VectorStore::Registry — resolves adapter from VECTOR_STORE_PROVIDER env
- VectorStore::Response — success/failure wrapper (like Provider::Response)

Consumers updated to go through VectorStore.adapter:
- Family::VectorSearchable
- Assistant::Function::SearchFamilyImportedFiles
- FamilyDocument

Removed: Provider::VectorStoreConcept, vector store methods from Provider::Openai

https://claude.ai/code/session_01TSkKc7a9Yu2ugm1RvSf4dh

* Add Vector Store configuration docs to ai.md

Documents how to configure the document search feature, covering all
three supported backends (OpenAI, pgvector, Qdrant), environment
variables, Docker Compose examples, supported file types, and privacy
considerations.

https://claude.ai/code/session_01TSkKc7a9Yu2ugm1RvSf4dh

* No need to specify `imported` in code

* Missed a couple more places

* Tiny reordering for the human OCD

* Update app/models/assistant/function/search_family_files.rb

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

* PR comments

* More PR comments

---------

Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-11 15:22:56 +01:00
BitToby ba6e286b41 feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support… (#894)
* feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support self-signed certificates in self-hosted environments

* fix: NoMethodError by defining SSL helper methods before configure block executes

* refactor: Refactor SessionsController to use shared SslConfigurable module and simplify SSL initializer redundant checks

* refactor: improve SSL configuration robustness and error detection accuracy

* fix:HTTParty SSL options, add file validation guards, prevent Tempfile GC, and redact URLs in error logs

* fix:  Fix SSL concern indentation and stub Simplefin POST correctly in tests

* fix: normalize ssl_verify to always return boolean instead of nil

* fix: solve failing SimpleFin test

* refactor:  trim unused error-handling code from SslConfigurable, replace Tempfile with fixed-path CA bundle, fix namespace pollution in initializers, and add unit tests for core SSL configuration and Langfuse CRL callback.

* fix: added require ileutils in the initializer and require ostruct in the test file.

* fix: solve autoload conflict that broke provider loading, validate all certs in PEM bundles, and add missing requires.
2026-02-06 18:04:03 +01:00
6f8858b1a6 feat/Add AI-Powered Bank Statement Import (step 1, PDF import & analysis) (#808)
* feat: Add PDF import with AI-powered document analysis

This enhances the import functionality to support PDF files with AI-powered
document analysis. When a PDF is uploaded, it is processed by AI to:
- Identify the document type (bank statement, credit card statement, etc.)
- Generate a summary of the document contents
- Extract key metadata (institution, dates, balances, transaction count)

After processing, an email is sent to the user asking for next steps.

Key changes:
- Add PdfImport model for handling PDF document imports
- Add Provider::Openai::PdfProcessor for AI document analysis
- Add ProcessPdfJob for async PDF processing
- Add PdfImportMailer for user notification emails
- Update imports controller to detect and handle PDF uploads
- Add PDF import option to the new import page
- Add i18n translations for all new strings
- Add comprehensive tests for the new functionality

* Add bank statement import with AI extraction

- Create ImportBankStatement assistant function for MCP
- Add BankStatementExtractor with chunked processing for small context windows
- Register function in assistant configurable
- Make PdfImport#pdf_file_content public for extractor access
- Increase OpenAI request timeout to 600s for slow local models
- Increase DB connection pool to 20 for concurrent operations

Tested with M-Pesa bank statement via remote Ollama (qwen3:8b):
- Successfully extracted 18 transactions
- Generated CSV and created TransactionImport
- Works with 3000 char chunks for small context windows

* Add pdf-reader gem dependency

The BankStatementExtractor uses PDF::Reader to parse bank statement
PDFs, but the gem was not properly declared in the Gemfile. This would
cause NameError in production when processing bank statements.

Added pdf-reader ~> 2.12 to Gemfile dependencies.

* Fix transaction deduplication to preserve legitimate duplicates

The previous deduplication logic removed ALL duplicate transactions based
on [date, amount, name], which would drop legitimate same-day duplicates
like multiple ATM withdrawals or card authorizations.

Changed to only deduplicate transactions that appear in consecutive chunks
(chunking artifacts) while preserving all legitimate duplicates within the
same chunk or non-adjacent chunks.

* Refactor bank statement extraction to use public provider method

Address code review feedback:
- Add public extract_bank_statement method to Provider::Openai
- Remove direct access to private client via send(:client)
- Update ImportBankStatement to use new public method
- Add require 'set' to BankStatementExtractor
- Remove PII-sensitive content from error logs
- Add defensive check for nil response.error
- Handle oversized PDF pages in chunking logic
- Remove unused process_native and process_generic methods
- Update email copy to reflect feature availability
- Add guard for nil document_type in email template
- Document pdf-reader gem rationale in Gemfile

Tested with both OpenAI (gpt-4o) and Ollama (qwen3:8b):
- OpenAI: 49 transactions extracted in 30s
- Ollama: 40 transactions extracted in 368s
- All encapsulation and error handling working correctly

* Update schema.rb with ai_summary and document_type columns

* Address PR #808 review comments

- Rename :csv_file to :import_file across controllers/views/tests
- Add PDF test fixture (sample_bank_statement.pdf)
- Add supports_pdf_processing? method for graceful degradation
- Revert unrelated database.yml pool change (600->3)
- Remove month_start_day schema bleed from other PR
- Fix PdfProcessor: use .strip instead of .strip_heredoc
- Add server-side PDF magic byte validation
- Conditionally show PDF import option when AI provider available
- Fix ProcessPdfJob: sanitize errors, handle update failure
- Move pdf_file attachment from Import to PdfImport
- Document deduplication logic limitations
- Fix ImportBankStatement: catch specific exceptions only
- Remove unnecessary require 'set'
- Remove dead json_schema method from PdfProcessor
- Reduce default OpenAI timeout from 600s to 60s
- Fix nil guard in text mailer template
- Add require 'csv' to ImportBankStatement
- Remove Gemfile pdf-reader comment

* Fix RuboCop indentation in ProcessPdfJob

* Refactor PDF import check to use model predicate method

Replace is_a?(PdfImport) type check with requires_csv_workflow? predicate
that leverages STI inheritance for cleaner controller logic.

* Fix missing 'unknown' locale key and schema version mismatch

- Add 'unknown: Unknown Document' to document_types locale
- Fix schema version to match latest migration (2026_01_24_180211)

* Document OPENAI_REQUEST_TIMEOUT env variable

Added to .env.local.example and docs/hosting/ai.md

* Rename ALLOWED_MIME_TYPES to ALLOWED_CSV_MIME_TYPES for clarity

* Add comment explaining requires_csv_workflow? predicate

* Remove redundant required_column_keys from PdfImport

Base class already returns [] by default

* Add ENV toggle to disable PDF processing for non-vision endpoints

OPENAI_SUPPORTS_PDF_PROCESSING=false can be used for OpenAI-compatible
endpoints (e.g., Ollama) that don't support vision/PDF processing.

* Wire up transaction extraction for PDF bank statements

- Add extracted_data JSONB column to imports
- Add extract_transactions method to PdfImport
- Call extraction in ProcessPdfJob for bank statements
- Store transactions in extracted_data for later review

* Fix ProcessPdfJob retry logic, sanitize and localize errors

- Allow retries after partial success (classification ok, extraction failed)
- Log sanitized error message instead of raw message to avoid data leakage
- Use i18n for user-facing error messages

* Add vision-capable model validation for PDF processing

* Fix drag-and-drop test to use correct field name csv_file

* Schema bleedover from another branch

* Fix drag-drop import form field name to match controller

* Add vision capability guard to process_pdf method

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: mkdev11 <jaysmth689+github@users.noreply.github.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-01-30 20:44:25 +01:00
eureka928 02c71bca0a Add AI Cache Management documentation
Document the AI cache reset feature including what it does, when to use it,
how to reset via UI, and cost implications.
2026-01-26 10:41:14 +01:00
Juan José Mata 16e4c4ede4 Small QOL fix in shell samples
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2026-01-22 08:14:43 +01:00
Nicolas PERNOT 73c816559d MERGE doc/adding_https_info INTO main (#709)
* Suggest to use .env.example

it's easier to start with a pre-done file than search for info about each configuration

* added paragraph to tell how to activate https

* fix type.

As there was 2 space, I assumed there should be a line break

* Corrects minor typos in HTTPS documentation

Addresses a couple of minor typographical errors in the HTTPS documentation section, improving clarity and readability for users setting up HTTPS.

* Clarifies .env setup in Docker documentation

Corrects grammar and improves clarity in the Docker documentation regarding obtaining the `.env.example` file for initial configuration.

* Fixes typo in HTTPS documentation

Corrects a grammatical error in the Docker HTTPS setup guide, improving clarity for users configuring SSL.
2026-01-19 19:36:14 +01:00
LPWandJosh Waldrep 320e087a22 Add support for displaying and managing legacy SSO providers (#628)
* feat: add support for displaying and managing legacy SSO providers

- Introduced UI section for environment/YAML-configured SSO providers.
- Added warnings and guidance on migrating legacy providers to database-backed configuration.
- Enhanced localization with new keys for legacy provider management.
- Updated form and toggle components for improved usability.

* Expand SSO documentation: add SAML 2.0 support, JIT provisioning settings, super-admin setup steps, audit logging, and user administration details.

* Update JIT provisioning docs: clarify role mapping behavior and add examples; note new `logout_idp` audit log event.

---------

Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
2026-01-13 09:37:19 +01:00
Josh Waldrep 238fa8e0ca Merge remote-tracking branch 'upstream/main' into sso-upgrades
# Conflicts:
#	app/views/simplefin_items/_simplefin_item.html.erb
#	db/schema.rb
2026-01-10 11:57:23 -05:00
zenaufaandJuan José Mata ae3eb0abf1 Added troubleshooting information for CSV import. (#558)
* Document CSV import processing delay issue

Added troubleshooting information for CSV import delays.

Signed-off-by: zenaufa <zenaufa@hotmail.com>

* Small edits suggested by LLM

---------

Signed-off-by: zenaufa <zenaufa@hotmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-01-07 21:27:12 +01:00
Josh Waldrep 14993d871c feat: comprehensive SSO/OIDC upgrade with enterprise features
Multi-provider SSO support:
   - Database-backed SSO provider management with admin UI
   - Support for OpenID Connect, Google OAuth2, GitHub, and SAML 2.0
   - Flipper feature flag (db_sso_providers) for dynamic provider loading
   - ProviderLoader service for YAML or database configuration

   Admin functionality:
   - Admin::SsoProvidersController for CRUD operations
   - Admin::UsersController for super_admin role management
   - Pundit policies for authorization
   - Test connection endpoint for validating provider config

   User provisioning improvements:
   - JIT (just-in-time) account creation with configurable default role
   - Changed default JIT role from admin to member (security)
   - User attribute sync on each SSO login
   - Group/role mapping from IdP claims

   SSO identity management:
   - Settings::SsoIdentitiesController for users to manage connected accounts
   - Issuer validation for OIDC identities
   - Unlink protection when no password set

   Audit logging:
   - SsoAuditLog model tracking login, logout, link, unlink, JIT creation
   - Captures IP address, user agent, and metadata

   Advanced OIDC features:
   - Custom scopes per provider
   - Configurable prompt parameter (login, consent, select_account, none)
   - RP-initiated logout (federated logout to IdP)
   - id_token storage for logout

   SAML 2.0 support:
   - omniauth-saml gem integration
   - IdP metadata URL or manual configuration
   - Certificate and fingerprint validation
   - NameID format configuration
2026-01-03 17:56:42 -05:00
LPWandJosh Waldrep b23711ae0d Add configurable multi-provider SSO, SSO-only mode, and JIT controls via auth.yml (#441)
* Add configuration and logic for dynamic SSO provider support and stricter JIT account creation

- Introduced `config/auth.yml` for centralized auth configuration and documentation.
- Added support for multiple SSO providers, including Google, GitHub, and OpenID Connect.
- Implemented stricter JIT SSO account creation modes (`create_and_link` vs `link_only`).
- Enabled optional restriction of JIT creation by allowed email domains.
- Enhanced OmniAuth initializer for dynamic provider setup and better configurability.
- Refined login UI to handle local login disabling and emergency super-admin override.
- Updated account creation flow to respect JIT mode and domain checks.
- Added tests for SSO account creation, login form visibility, and emergency overrides.

# Conflicts:
#	app/controllers/sessions_controller.rb

* remove non-translation

* Refactor authentication views to use translation keys and update locale files

- Extracted hardcoded strings in `oidc_accounts/link.html.erb` and `sessions/new.html.erb` into translation keys for better localization support.
- Added missing translations for English and Spanish in `sessions` and `oidc_accounts` locale files.

* Enhance OmniAuth provider configuration and refine local login override logic

- Updated OmniAuth initializer to support dynamic provider configuration with `name` and scoped parameters for Google and GitHub.
- Improved local login logic to enforce stricter handling of super-admin override when local login is disabled.
- Added test for invalid super-admin override credentials.

* Document Google sign-in configuration for local development and self-hosted environments

---------

Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
2025-12-24 00:15:53 +01:00
Blaž Dular 8972cb59f0 docs: add env variable for ai debug to docs (#494) 2025-12-23 19:57:32 +01:00
Juan José Mata c47a790ad9 We tag alphas as latest now
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2025-11-11 11:07:06 +01:00
soky srmandJuan José Mata da114b5b3d Update ai.md (#263)
* Update ai.md

Change some deprecated models

Signed-off-by: soky srm <sokysrm@gmail.com>

* Fix typo in AI model description

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2025-10-30 23:38:14 +01:00
Juan José Mata f18c11c7ac Update AI model recommendations section
Added a caution note about model support and testing approach.

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2025-10-29 18:52:26 +01:00
Juan José Mata 3f4330eea8 Update AI assistant documentation with version caution
Added caution note regarding AI assistant support versions.

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2025-10-29 18:44:58 +01:00
Juan José Mataandsokie 768e85ce08 Add OpenID Connect login support (#77)
* Add OpenID Connect login support
* Add docs for OIDC config with Google Auth
* Use Google styles for log in
- Add support for linking existing account
- Force users to sign-in with passoword first, when linking existing accounts
- Add support to create new user when using OIDC
- Add identities to user to prevent account take-ver
- Make tests mocking instead of being integration tests
- Manage session handling correctly
- use OmniAuth.config.mock_auth instead of passing auth data via request env
* Conditionally render Oauth button

- Set a config item `configuration.x.auth.oidc_enabled`
- Hide button if disabled

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Signed-off-by: soky srm <sokysrm@gmail.com>
Co-authored-by: sokie <sokysrm@gmail.com>
2025-10-24 16:07:45 +02:00
Copilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>jjmataJuan José Mata
a8f318c3f9 Fix "Messages is invalid" error for Ollama/custom LLM providers and add comprehensive AI documentation (#225)
* Add comprehensive AI/LLM configuration documentation
* Fix Chat.start! to use default model when model is nil or empty
* Ensure all controllers use Chat.default_model for consistency
* Move AI doc inside `hosting/`
* Probably too much error handling

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2025-10-24 12:04:19 +02:00
Juan José Mataandcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> 7c5ddd674d Make branding configurable (#173)
* Remove orphan function

* Add centralized branding helpers and update locales

* Remove _plus and add (proper) brand

* No longer Sure, configurable

* Consistency with compose file naming

* Missed `product_name` mapping

* Fix brand/product name in mailers

* Product name in email reset flow

* Fix i18n errors/tests

* Fix password mailer brand/product name (again)

* Missed hardcoded `Sure` in onboarding goals

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

* PR nitpick on documentation

* Missing interpolation key for invited UI

* Orphan assets

* New logos

---------

Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-10-22 19:14:03 +02:00
Pedro Camara Junior 3aea1513d1 Add comprehensive Hetzner Cloud deployment guide (#211)
* Add comprehensive Hetzner Cloud deployment guide

* Fix markdown linting issues and backup retention policy

- Add missing language identifiers to fenced code blocks (bash)
- Fix inconsistent backup retention policy (standardize to 7 days)
- Address CodeRabbit review feedback for PR #211
2025-10-21 15:34:44 +02:00
Juan José Mata 5706280dd7 More rebranding changes (#159)
* Replace Maybe for Sure in select code areas

* Make sure passwords are consistent

* Remove (admin|member) from demo data first name

* Database and schema names finally to `sure`

* Fix broken test

* Another (benchmarking) database name to `sure_*`

* More rebranding to Sure

* Missed this Maybe mention in the same page

* Random nitpicks and more Maybes

* Demo data accounts and more Maybes

* Test data account updates

* Impersonation test accounts

* Consistency with `compose.example.yml`
2025-09-24 00:19:51 +02:00