draw the wallet from the capability, not from a provider row

Wallet sign-in was unreachable on hanzo.id. The entry was built out of the
application's provider LIST, and IAM no longer publishes a web3 row there —
three independent reasons, none of which will change:

  - the seeded provider-web3 record is category "OAuth" with the unexpanded
    clientId ${IAM_WEB3_CLIENT_ID}, so IAM's own `offerable` strips it before
    the descriptor leaves the server;
  - all 80 applications that link it set canSignIn:false;
  - IAM deliberately MOVED the capability off the row (pkg/schema/wallet.go:
    the row "names a third-party library this build does not import") and
    answers it from schema.WalletChains() on /v1/iam/auth/methods.

Live get-app-login for hanzo-console returns exactly [provider-github,
provider-google]; a headless browser on hanzo.id/login found no
data-provider="web3" anywhere — while /v1/iam/web3/nonce answered a real
CAIP-122 challenge for evm, solana AND bitcoin. So a wallet-only account,
provisioned by /v1/iam/web3/verify and holding no password, had no way in.

The screen now asks the question IAM actually answers. Two reads, because
they are two different questions and conflating them is what broke this:
get-app-login says what this APPLICATION offers, auth/methods says what this
BINARY can do. Neither read can remove the other's buttons.

ENABLED_WALLET_CHAINS is deleted. It was a copy of a server policy ("TON /
XRP / Bitcoin verifiers are still stubs") kept in the browser, where it could
only ever drift — and had: IAM accepts seven families, this bundle can sign
five, and the copy offered two. What is offered is now the intersection of
what IAM will verify and what @hanzo/id-connect has a connector for, so the
screen can neither offer a chain the verifier refuses nor hide one it accepts.
PROVIDER_ORDER stays the ONE order for the whole strip, wallet included.

The capability read goes through the client like every other IAM read, so
there is one fetch seam rather than a module reaching for global fetch.

And the runner can finally hold any of this: it collected only *.test.ts in a
`node` environment with no document, so WHICH method renders WHEN was
unverifiable — a whole sign-in method went missing with `pnpm tc` and
`pnpm test` both green, and the one page-level test reads Login.tsx as TEXT
and matches regexes against it. *.test.tsx now runs in a DOM; the four new
mounted tests fail against the provider-row gate and pass against this.
This commit is contained in:
2026-08-07 22:28:19 -07:00
parent 8ad7fcc4f0
commit 457a6c1c45
9 changed files with 512 additions and 114 deletions
+2
View File
@@ -13,6 +13,8 @@
"clean": "bash scripts/clean.sh"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"happy-dom": "^20.11.2",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
+23 -5
View File
@@ -1,4 +1,6 @@
import type { OrgConfig } from '@hanzo/id-shared'
import type { Chain } from '@hanzo/id-connect'
import { offeredWalletChains } from './web3'
import type {
AppLogin,
AppProvider,
@@ -122,11 +124,11 @@ export interface AuthClient {
*/
signOut(postLogoutRedirectUri?: string): string
/**
* Read the live enabled-auth-methods view for an application from
* `/v1/iam/get-app-login` — the canonical source of truth for which
* sign-in buttons (password / GitHub / Google / Web3) to render.
* Resolves to null when the endpoint is unreachable so callers can fall
* back to the org's declared default method set.
* Read the live login view of an application from `/v1/iam/get-app-login` —
* what THIS APPLICATION offers (password, code, signup, its social providers),
* each already masked by IAM with the capability behind it. Resolves to null when
* the endpoint is unreachable, so a caller renders no method rather than a
* guessed one.
*
* `redirectUri` is validated by IAM against the app's registered list. For a
* cross-app SSO read (e.g. console → hanzo.id, `clientId=hanzo-cloud`) pass the
@@ -136,6 +138,16 @@ export interface AuthClient {
* response. Omit it for a bare/own-app read (defaults to the portal callback).
*/
getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null>
/**
* The chain families a wallet may sign in with here.
*
* A SECOND read, deliberately, because it answers a different question than
* {@link getAppLogin}: wallet sign-in is a capability of the IAM BINARY, not of
* an application's config, so IAM publishes it on `/v1/iam/auth/methods` (from
* the same list its nonce and verify endpoints gate on) and nowhere else. Empty
* means no wallet — which is also what an unreadable answer means.
*/
walletChains(clientId?: string): Promise<Chain[]>
/**
* Resolve the signed-in user's `{owner, name}` from the IAM session
* (`/v1/iam/get-account`). After a `RequiredMfa` login the IAM session cookie
@@ -559,6 +571,11 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
return parseAppLogin(body.data as Record<string, unknown>, org.appName, org.orgId)
}
// The narrowing itself lives with the wallet flow (it is that module's business
// what it can sign); this supplies the transport, so every IAM read in this
// client goes through one fetch.
const walletChains = (clientId?: string) => offeredWalletChains(org, clientId ?? org.clientId, f)
async function getAccount(): Promise<MfaIdentity | null> {
const url = new URL('/v1/iam/get-account', org.iamUrl)
let body: Record<string, unknown>
@@ -679,6 +696,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
logout,
signOut,
getAppLogin,
walletChains,
getAccount,
mfaInitiate,
mfaVerify,
-1
View File
@@ -10,7 +10,6 @@ export { authorizeRequest, matchProviderHint } from './social'
export {
loginWithWalletChain,
detectWalletChains,
ENABLED_WALLET_CHAINS,
WALLET_CHAIN_LABELS,
type WalletLoginContext,
type WalletWindow,
+150
View File
@@ -0,0 +1,150 @@
/**
* What the sign-in strip OFFERS, mounted for real.
*
* The rule under test is one sentence: a button appears exactly when the server
* can complete what it starts. That was previously unverifiable — the runner
* collected no .tsx and had no document — so the strip could lose a whole sign-in
* method with both gates green, which is what happened to the wallet.
*/
import { afterEach, test } from 'vitest'
import assert from 'node:assert/strict'
import { cleanup, render, waitFor } from '@testing-library/react'
import type { OrgConfig } from '@hanzo/id-shared'
import { createAuthClient } from '../client'
import { SocialButtons } from './SocialButtons'
afterEach(cleanup)
function org(): OrgConfig {
return {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
iamIssuer: 'https://hanzo.id',
clientId: 'hanzo-console',
appName: 'hanzo-console',
publicOrigin: 'https://hanzo.id',
brandPackage: '@hanzo/brand',
} as OrgConfig
}
/**
* An IAM double answering both descriptors the strip reads, with the shapes live
* prod returns: get-app-login carries the app's provider list (already filtered by
* IAM's own `offerable`), auth/methods carries the binary's capabilities.
*/
function iam(opts: { providers?: unknown[]; chains?: string[] }) {
const json = (payload: unknown) =>
new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })
return (async (input: RequestInfo | URL) => {
const url = input.toString()
if (url.includes('/auth/methods')) {
return json({ status: 'ok', data: { web3: (opts.chains ?? []).length > 0, web3Chains: opts.chains ?? [] } })
}
return json({
status: 'ok',
data: { owner: 'admin', name: 'hanzo-console', organization: 'hanzo', providers: opts.providers ?? [] },
})
}) as unknown as typeof fetch
}
/** The buttons currently drawn, in DOM order, by their provider key. */
function drawn(): (string | undefined)[] {
return [...document.querySelectorAll('[data-provider]')].map((b) => (b as HTMLElement).dataset.provider)
}
/** Resolve when the strip has rendered (both descriptor reads have landed). */
async function settled(): Promise<void> {
await waitFor(() => assert.ok(document.querySelector('.hanzo-id-social')))
}
const google = {
name: 'provider-google',
canSignIn: true,
provider: { name: 'provider-google', type: 'Google', clientId: '113591532635-real.apps.googleusercontent.com' },
}
const github = {
name: 'provider-github',
canSignIn: true,
provider: { name: 'provider-github', type: 'GitHub', clientId: 'Iv23li3SYLoq40ExR6EN' },
}
// THE DEFECT. The wallet entry was built from the app's provider LIST, and IAM
// stopped publishing a web3 row there: the seeded provider-web3 is category "OAuth"
// with the unexpanded clientId `${IAM_WEB3_CLIENT_ID}`, IAM's `offerable` drops it,
// and all 80 apps that link it set canSignIn:false. Live get-app-login for
// hanzo-console returns [provider-github, provider-google] and nothing else, so no
// wallet button was drawn on hanzo.id — while /v1/iam/web3/nonce answered a real
// CAIP-122 challenge for evm, solana and bitcoin, and a wallet-only account
// (provisioned by web3/verify, holding no password) had no way to sign in.
test('the wallet entry comes from the capability, with no provider row anywhere', async () => {
render(
<SocialButtons
client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [google, github], chains: ['evm', 'solana', 'bitcoin'] }) })}
/>,
)
await settled()
assert.deepEqual(
drawn(),
['google', 'github', 'web3'],
'the descriptor carries NO web3 provider row, and the wallet entry must still be drawn',
)
assert.equal(document.querySelector('[data-wallet-connect="true"]')?.textContent, 'Connect Wallet')
})
test('a provider row can no longer conjure a wallet the server cannot verify', async () => {
// The inverse of the defect, and the reason this is a capability read rather than
// a wider provider filter: a row is not a capability. If IAM's chain list is
// empty, no wallet entry — whatever any app happens to link.
const web3Row = {
name: 'provider-web3',
canSignIn: true,
provider: { name: 'provider-web3', type: 'Web3Onboard', clientId: 'real-looking-id' },
}
render(
<SocialButtons client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [google, web3Row], chains: [] }) })} />,
)
await settled()
assert.deepEqual(drawn(), ['google'], 'a linked provider row must not draw a wallet entry')
})
// The strip's order is a product decision that lived in PROVIDER_ORDER and must
// keep governing the wallet too, now that the wallet is not a provider row.
test('google leads and the wallet trails', async () => {
render(
<SocialButtons
client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [github, google], chains: ['evm'] }) })}
/>,
)
await settled()
// The descriptor listed github FIRST; the strip still leads with google.
assert.deepEqual(drawn(), ['google', 'github', 'web3'])
})
// A chain nobody can sign is a dead end of the same kind as a dead OAuth button.
test('only chains this bundle can sign reach the chooser', async () => {
render(
<SocialButtons
client={createAuthClient({
org: org(),
fetchImpl: iam({ chains: ['evm', 'solana', 'bitcoin', 'ton', 'xrp', 'polkadot', 'cardano'] }),
})}
/>,
)
await settled()
// No injected wallet in this document, so the entry reveals the chooser rather
// than guessing — every offered family stays reachable from it.
document.querySelector<HTMLButtonElement>('[data-wallet-connect="true"]')!.click()
const chains = await waitFor(() => {
const found = [...document.querySelectorAll('[data-chain]')].map((b) => (b as HTMLElement).dataset.chain)
assert.ok(found.length > 0, 'the chooser did not open')
return found
})
assert.deepEqual(chains, ['evm', 'solana', 'bitcoin', 'ton', 'xrp'],
'polkadot and cardano have no connector here, so they must not be offered')
})
+60 -47
View File
@@ -5,37 +5,43 @@ import type { AuthClient } from '../client'
import type { AppProvider } from '../types'
import { authorizeRequest, matchProviderHint, PROVIDER_ORDER } from '../social'
import { createIam } from '../iam'
import {
loginWithWalletChain,
detectWalletChains,
ENABLED_WALLET_CHAINS,
WALLET_CHAIN_LABELS,
} from '../web3'
import { loginWithWalletChain, detectWalletChains, WALLET_CHAIN_LABELS } from '../web3'
import { GitHubIcon, GitLabIcon, GoogleIcon, WalletIcon } from './icons'
import { Divider } from './Divider'
/**
* Social + multi-chain wallet sign-in buttons.
*
* The enabled set is read live from `/v1/iam/get-app-login` (via
* `client.getAppLogin()`) — the canonical source of truth that mirrors the
* per-app provider config in `init_data.json`. We render ONLY providers IAM
* holds real credentials for (`AppProvider.configured`); a provider seeded with
* placeholder creds is hidden so its button never dead-ends, and reappears once
* real creds land. When the config is unreadable we render none.
* Two sign-in shapes, decomplected — and they are answered by two DIFFERENT
* questions, which is the whole shape of this component:
*
* Two sign-in shapes, decomplected:
* - OAuth (github/google/gitlab) → FEDERATION: name the provider on IAM's own
* authorize endpoint (`?provider=provider-github`) and let IAM run the entire
* IdP leg server-side, where the client secret lives. See `social.ts`. This
* browser never builds an IdP URL and never sees a provider code.
* - Web3/wallet → native Sign-In-With-X (`loginWithWalletChain`): connect a
* wallet with `@hanzo/id-connect` (no WalletConnect, no projectId), sign the
* - OAuth (github/google/gitlab) is per-APPLICATION config, so it comes from
* `/v1/iam/get-app-login`: which providers this app links, and IAM has already
* dropped the ones that cannot complete (`offerable` — no real credential, or
* no federation dialect that can drive it). Starting one is FEDERATION: name
* the provider on IAM's own authorize endpoint (`?provider=provider-github`)
* and let IAM run the entire IdP leg server-side, where the client secret
* lives. See `social.ts`. This browser never builds an IdP URL and never sees
* a provider code.
*
* - Wallet sign-in is a capability of the IAM BINARY, not of an app's provider
* list, so it is asked of `/v1/iam/auth/methods` (`offeredWalletChains`).
* Reading it off a linked provider is what made it unreachable: the seeded
* provider-web3 row is category "OAuth" with the unexpanded clientId
* `${IAM_WEB3_CLIENT_ID}`, all 80 apps that link it set canSignIn:false, and
* IAM drops it from the descriptor's provider list entirely — while
* /v1/iam/web3/nonce answers a real CAIP-122 challenge on seven families. So
* the button was gated on a row that governs nothing and a wallet-only account
* (provisioned by /v1/iam/web3/verify, holding no password) had no way in.
* The flow itself is native Sign-In-With-X (`loginWithWalletChain`): connect
* with `@hanzo/id-connect` (no WalletConnect, no projectId), sign the
* IAM-minted challenge, POST `/v1/iam/web3/verify`, then follow the SAME
* redirect the password flow returns. The wallet provider renders ONE
* chain-agnostic "Connect Wallet" button: it auto-detects the injected
* chain (`detectWalletChains`) and connects straight when exactly one is
* present, else reveals a chooser so either EVM or Solana stays reachable.
* redirect the password flow returns. It renders ONE chain-agnostic "Connect
* Wallet" entry: it auto-detects the injected chain (`detectWalletChains`) and
* connects straight when exactly one is present, else reveals a chooser so
* every offered family stays reachable.
*
* When either read fails we render nothing for it rather than a maybe-dead button.
*/
export interface SocialButtonsProps {
readonly client: AuthClient
@@ -86,6 +92,8 @@ const PROVIDER_META: Record<string, ProviderMeta> = {
interface Resolved {
/** Configured + renderable providers, keyed by their normalized key. */
readonly providers: Record<string, AppProvider>
/** Chain families a wallet may sign in with here; empty → no wallet entry. */
readonly wallet: readonly Chain[]
}
export function SocialButtons({
@@ -154,29 +162,31 @@ export function SocialButtons({
typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get('redirect_uri') ?? undefined
: undefined
client
.getAppLogin(clientIdOverride, oidcRedirectUri)
.then((app) => {
// Two reads, two questions: what this APPLICATION offers, and what this IAM
// can verify a wallet signature for. They are independent, so one failing
// never removes the other's buttons — offeredWalletChains resolves to [].
Promise.all([
client.getAppLogin(clientIdOverride, oidcRedirectUri),
client.walletChains(clientIdOverride),
])
.then(([app, wallet]) => {
if (cancelled) return
if (!app) {
// Can't read the app config → render no social rather than risk a
// dead-end button. Password / email-code still render.
setResolved({ providers: {} })
setResolved({ providers: {}, wallet })
onAutoStartResolved?.(false)
return
}
const want = intent === 'signup' ? (p: AppProvider) => p.canSignUp : (p: AppProvider) => p.canSignIn
// Render ONLY providers IAM actually holds credentials for. A provider
// with placeholder/empty creds would dead-end the OAuth redirect, so we
// hide it; it reappears automatically once real creds are seeded. Web3
// needs no IAM-side OAuth credential (the wallet IS the credential), so
// it renders whenever the app enables it.
// hide it; it reappears automatically once real creds are seeded.
const providers: Record<string, AppProvider> = {}
for (const p of app.providers) {
const enabled = p.key === 'web3' ? want(p) : want(p) && p.configured
if (enabled && p.key in PROVIDER_META) providers[p.key] = p
if (want(p) && p.configured && p.key in PROVIDER_META) providers[p.key] = p
}
setResolved({ providers })
setResolved({ providers, wallet })
// A client that already knows the provider (console `?provider_hint=…`)
// launches it straight away — the SAME hop the button runs, so a click
// over there lands directly in the provider flow, no second press and no
@@ -196,7 +206,7 @@ export function SocialButtons({
})
.catch(() => {
if (cancelled) return
setResolved({ providers: {} })
setResolved({ providers: {}, wallet: [] })
onAutoStartResolved?.(false)
})
return () => {
@@ -211,7 +221,12 @@ export function SocialButtons({
// above; the caller renders its own "signing you in" state. Render nothing.
if (autoStart) return null
if (resolved === null) return null // resolving — render nothing rather than flicker
const ordered = PROVIDER_ORDER.filter((k) => k in resolved.providers)
// ONE order for the whole strip, wallet included — PROVIDER_ORDER is where that
// decision lives (google leads, the wallet trails). The wallet's slot is filled
// by the capability, every other slot by the app's own provider list.
const ordered = PROVIDER_ORDER.filter((k) =>
k === 'web3' ? resolved.wallet.length > 0 : k in resolved.providers,
)
if (ordered.length === 0) return null
const verb = intent === 'signup' ? 'Sign up' : 'Continue'
@@ -247,12 +262,12 @@ export function SocialButtons({
}
}
// The chain-agnostic entry: auto-detect the injected wallet and connect
// straight when exactly one chain is available; otherwise reveal the chooser
// so the user picks EVM or Solana. Both underlying flows stay reachable.
// The chain-agnostic entry: auto-detect the injected wallet and connect straight
// when exactly one of the OFFERED chains is available; otherwise reveal the
// chooser so the person picks. Every offered family stays reachable.
function onConnectWallet() {
setError(null)
const detected = detectWalletChains()
const detected = detectWalletChains(resolved!.wallet)
if (detected.length === 1) startWallet(detected[0]!)
else setWalletMenu(true)
}
@@ -261,14 +276,11 @@ export function SocialButtons({
<>
<div className="hanzo-id-social">
{ordered.map((k) => {
const provider = resolved.providers[k]!
// Web3 expands into one connect button per ENABLED chain; OAuth
// providers render a single hop button.
if (k === 'web3') {
// ONE chain-agnostic entry. It connects straight when a single
// wallet is detected, else expands into the chooser below — so the
// page always shows exactly one "Connect Wallet" button, with both
// EVM and Solana reachable from it.
// ONE chain-agnostic entry. It connects straight when a single wallet
// is detected, else expands into the chooser below — so the page always
// shows exactly one "Connect Wallet" button, with every offered family
// reachable from it.
return (
<Fragment key="web3">
<button
@@ -289,7 +301,7 @@ export function SocialButtons({
role="group"
aria-label="Choose a wallet network"
>
{ENABLED_WALLET_CHAINS.map((chain) => (
{resolved.wallet.map((chain) => (
<button
key={`web3-${chain}`}
type="button"
@@ -308,6 +320,7 @@ export function SocialButtons({
</Fragment>
)
}
const provider = resolved.providers[k]!
const meta = PROVIDER_META[k]!
const { Icon } = meta
return (
+59 -37
View File
@@ -13,10 +13,11 @@ import { createAuthClient } from './client.ts'
import {
loginWithWalletChain,
detectWalletChains,
ENABLED_WALLET_CHAINS,
offeredWalletChains,
WALLET_CHAIN_LABELS,
type WalletSigner,
} from './web3.ts'
import { CHAINS } from '@hanzo/id-connect'
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
import type { OrgConfig } from '@hanzo/id-shared'
@@ -153,28 +154,47 @@ test('SSO flow (downstream redirectUri) sends type=code and returns the app redi
)
})
test('disabled chains are not offered and fail closed without any network or signer call', async () => {
// The stub-verifier chains must NOT be in the enabled set...
for (const stub of ['ton', 'xrp', 'bitcoin'] as const) {
assert.equal(ENABLED_WALLET_CHAINS.includes(stub), false, `${stub} must be disabled`)
}
// ...and only the production-verifier chains are.
assert.deepEqual([...ENABLED_WALLET_CHAINS], ['evm', 'solana'])
// every enabled chain has a render label.
for (const c of ENABLED_WALLET_CHAINS) assert.ok(WALLET_CHAIN_LABELS[c])
// What wallet sign-in offers is the INTERSECTION of two facts that live on two
// different sides: the families IAM will verify (auth/methods -> web3Chains, read
// from the same list its nonce and verify endpoints gate on) and the families this
// bundle has a connector for (@hanzo/id-connect CHAINS).
//
// It used to be a hardcoded ['evm','solana'] in this module — a COPY of a server
// policy, which could only drift, and did: IAM answers a real CAIP-122 challenge on
// seven families, five of them signable here, and the copy offered two.
test('the offer is what IAM verifies, narrowed to what this browser can sign', async () => {
const answered = (chains: unknown) =>
(async () =>
new Response(JSON.stringify({ status: 'ok', data: { web3: true, web3Chains: chains } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})) as unknown as typeof fetch
// Calling a disabled chain returns an error and touches neither fetch nor signer.
const { calls, fetchImpl } = capturingFetch()
let signed = false
const sign: WalletSigner = async () => {
signed = true
throw new Error('signer must not run for a disabled chain')
}
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(client, 'ton', {}, fetchImpl, sign)
assert.match(res.error ?? '', /not enabled/)
assert.equal(calls.length, 0)
assert.equal(signed, false)
// The seven families live IAM publishes today.
assert.deepEqual(
await offeredWalletChains(org(), 'hanzo-console',
answered(['evm', 'solana', 'bitcoin', 'ton', 'xrp', 'polkadot', 'cardano'])),
['evm', 'solana', 'bitcoin', 'ton', 'xrp'],
'polkadot and cardano have no connector here, so they are not offered',
)
// A server that narrows its list narrows the screen with no client change.
assert.deepEqual(await offeredWalletChains(org(), 'hanzo-console', answered(['evm'])), ['evm'])
// And every chain that can be offered has a label to render.
for (const c of CHAINS) assert.ok(WALLET_CHAIN_LABELS[c])
})
test('an unreadable capability offers no wallet at all', async () => {
const dead = (async () => {
throw new Error('network down')
}) as unknown as typeof fetch
assert.deepEqual(await offeredWalletChains(org(), 'hanzo-console', dead), [])
const refused = (async () =>
new Response(JSON.stringify({ status: 'error', msg: 'the application does not exist' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})) as unknown as typeof fetch
assert.deepEqual(await offeredWalletChains(org(), 'hanzo-console', refused), [])
})
test('a wallet rejection surfaces as { error } (not a throw), and verify is never called', async () => {
@@ -192,27 +212,29 @@ test('a wallet rejection surfaces as { error } (not a throw), and verify is neve
test('detectWalletChains: a single injected wallet resolves to exactly its chain', () => {
// EVM only → [evm]; the UI connects straight, no chooser.
assert.deepEqual(detectWalletChains({ ethereum: {} }), ['evm'])
assert.deepEqual(detectWalletChains(CHAINS, { ethereum: {} }), ['evm'])
// Solana only, via any of the recognized injected providers → [solana].
assert.deepEqual(detectWalletChains({ solana: {} }), ['solana'])
assert.deepEqual(detectWalletChains({ solflare: {} }), ['solana'])
assert.deepEqual(detectWalletChains({ backpack: {} }), ['solana'])
assert.deepEqual(detectWalletChains(CHAINS, { solana: {} }), ['solana'])
assert.deepEqual(detectWalletChains(CHAINS, { solflare: {} }), ['solana'])
assert.deepEqual(detectWalletChains(CHAINS, { backpack: {} }), ['solana'])
})
test('detectWalletChains: both injected → both, in enabled order (chooser)', () => {
assert.deepEqual(detectWalletChains({ ethereum: {}, solana: {} }), ['evm', 'solana'])
test('detectWalletChains: both injected → both, in offered order (chooser)', () => {
assert.deepEqual(detectWalletChains(CHAINS, { ethereum: {}, solana: {} }), ['evm', 'solana'])
})
test('detectWalletChains: nothing injected → [] (chooser, both still reachable)', () => {
test('detectWalletChains never reaches past what is offered', () => {
// An injected EVM wallet on a screen offering only Solana is NOT auto-connected:
// detection is a narrowing of the offer, never an addition to it.
assert.deepEqual(detectWalletChains(['solana'], { ethereum: {} }), [])
})
test('detectWalletChains: nothing injected → [] (chooser, all still reachable)', () => {
// No window (server / node) and an empty window both resolve to none — the UI
// then reveals the chooser so EVM and Solana stay selectable regardless.
assert.deepEqual(detectWalletChains({}), [])
assert.deepEqual(detectWalletChains(undefined), [])
assert.deepEqual(detectWalletChains(), []) // node has no global window
// Every detectable chain is one the wallet flow actually enables.
for (const c of detectWalletChains({ ethereum: {}, solana: {} })) {
assert.ok(ENABLED_WALLET_CHAINS.includes(c))
}
// then reveals the chooser so every offered family stays selectable regardless.
assert.deepEqual(detectWalletChains(CHAINS, {}), [])
assert.deepEqual(detectWalletChains(CHAINS, undefined), [])
assert.deepEqual(detectWalletChains(CHAINS), []) // node has no global window
})
test('an IAM verify error is returned as { error }', async () => {
+44 -16
View File
@@ -19,6 +19,7 @@
*/
import type { OrgConfig } from '@hanzo/id-shared'
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
import { CHAINS } from '@hanzo/id-connect'
import type { AuthClient } from './client'
import type { LoginResponse } from './types'
@@ -38,13 +39,41 @@ const defaultSigner: WalletSigner = async (chain, challenge) => {
}
/**
* Chains whose wallet login is ENABLED. The server-side verifiers for EVM and
* Solana are production-grade; TON / XRP / Bitcoin verifiers are still stubs and
* would fail closed, so they are NOT offered. Adding a chain later is one line
* here (once its Go verifier is real). Single source of truth — the UI renders
* exactly this set.
* The chains a wallet may sign in with HERE: the families this IAM will verify a
* signature for, narrowed to the ones this browser bundle can produce one with.
*
* Two different facts, and neither belongs to the other side. IAM publishes what
* it accepts on `GET /v1/iam/auth/methods` as `web3Chains`, read from the same
* list its nonce and verify endpoints gate on (`schema.WalletChains`), so a screen
* cannot offer a chain the verifier refuses. `@hanzo/id-connect` publishes CHAINS,
* the families it has a connector for, so a screen cannot offer a chain this
* browser cannot sign. The offer is the intersection.
*
* This replaces a hardcoded two-chain list. That list was a COPY of a server
* policy ("TON / XRP / Bitcoin verifiers are still stubs") kept in the browser,
* where it could only ever drift: IAM answers a real CAIP-122 challenge on seven
* families today, five of which this bundle can sign, and the copy offered two.
*
* Fails closed to no chains — the same rule the social strip follows, since a
* button that cannot finish is worse than an absent one.
*/
export const ENABLED_WALLET_CHAINS: readonly Chain[] = ['evm', 'solana']
export async function offeredWalletChains(
org: OrgConfig,
clientId: string,
fetchImpl: typeof fetch = fetch,
): Promise<Chain[]> {
const url = new URL('/v1/iam/auth/methods', org.iamUrl)
url.searchParams.set('clientId', clientId)
try {
const res = await fetchImpl(url.toString(), { headers: { Accept: 'application/json' } })
const body = (await res.json()) as Record<string, unknown>
const data = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
if (body.status !== 'ok' || !Array.isArray(data.web3Chains)) return []
return CHAINS.filter((c) => (data.web3Chains as unknown[]).includes(c))
} catch {
return []
}
}
/** Display label per chain, shown on each connect button. */
export const WALLET_CHAIN_LABELS: Record<Chain, string> = {
@@ -74,24 +103,26 @@ function chainInjected(chain: Chain, w: WalletWindow): boolean {
case 'solana':
return Boolean(w.solana || w.solflare || w.backpack)
default:
// A chain with no sniff is never auto-detected; the chooser still offers
// it. Only the ENABLED set is ever consulted, so this stays unreachable.
// No sniff for this family, so it is never auto-detected — it stays
// reachable from the chooser, which is the honest answer for a wallet whose
// presence a page cannot read.
return false
}
}
/**
* The ENABLED wallet chains that currently have an injected provider. A pure
* Which of the `offered` chains have an injected provider right now. A pure
* `window` sniff — no connect, no I/O — that powers the chain-agnostic "Connect
* Wallet" entry: exactly one match → connect straight; zero or many → let the
* user pick. Derived from {@link ENABLED_WALLET_CHAINS} so there is ONE source
* of truth for what wallet login offers.
* Wallet" entry: exactly one match → connect straight; zero or many → let the user
* pick. The offer comes from the caller ({@link offeredWalletChains}), so what can
* be detected is always a subset of what can complete.
*/
export function detectWalletChains(
offered: readonly Chain[],
w: WalletWindow | undefined = typeof window === 'undefined' ? undefined : (window as WalletWindow),
): Chain[] {
if (!w) return []
return ENABLED_WALLET_CHAINS.filter((c) => chainInjected(c, w))
return offered.filter((c) => chainInjected(c, w))
}
/** Routing context for the verify POST — exactly what the password flow carries. */
@@ -132,9 +163,6 @@ export async function loginWithWalletChain(
sign: WalletSigner = defaultSigner,
): Promise<LoginResponse> {
const org = client.org
if (!ENABLED_WALLET_CHAINS.includes(chain)) {
return { error: `wallet login not enabled for ${chain}` }
}
// 1. Mint the challenge, then connect+sign atomically (the connector
// disconnects on failure). The nonce is fetched without an address — the
+160 -2
View File
@@ -12,12 +12,18 @@ importers:
.:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
happy-dom:
specifier: ^20.11.2
version: 20.11.2
typescript:
specifier: ^5.9.3
version: 5.9.3
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/[email protected])([email protected])([email protected])([email protected])
version: 3.2.7(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
apps/account:
devDependencies:
@@ -283,6 +289,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/[email protected]':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
@@ -952,6 +962,25 @@ packages:
'@tanstack/[email protected]':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@testing-library/[email protected]':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
'@testing-library/[email protected]':
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
engines: {node: '>=18'}
peerDependencies:
'@testing-library/dom': ^10.0.0
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@tonconnect/[email protected]':
resolution: {integrity: sha512-B4UoIjPi0QkvIzZH5fV3BQLWrqSYABdrzZQSI9sJA9aA+iC0ohOzFwVVGXanlxeDAy1bcvPbb29f6sVUk0UnnQ==}
@@ -980,6 +1009,9 @@ packages:
resolution: {integrity: sha512-Ox0JwpXa0pTIELMGqSQItMGM4JWWG/cMw7bceHrBBdNQtFP82edcD1o8vdApYZnezzbHMoITE/yz9IQ2q9Of/g==}
engines: {node: '>=10.13.0'}
'@types/[email protected]':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/[email protected]':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1033,6 +1065,12 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@types/[email protected]':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/[email protected]':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@vitejs/[email protected]':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -1095,6 +1133,17 @@ packages:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
[email protected]:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
[email protected]:
resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==}
@@ -1189,6 +1238,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
[email protected]:
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
engines: {node: '>=4.0'}
[email protected]:
resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==}
@@ -1283,10 +1336,17 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
[email protected]:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
[email protected]:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1297,6 +1357,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==}
[email protected]:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
[email protected]:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
@@ -1403,6 +1467,10 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==}
engines: {node: '>=20.0.0'}
[email protected]:
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
@@ -1585,6 +1653,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
[email protected]:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1698,6 +1770,10 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
[email protected]:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -1709,6 +1785,9 @@ packages:
peerDependencies:
react: ^19.2.7
[email protected]:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
[email protected]:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -2018,6 +2097,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
[email protected]:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -2189,6 +2272,8 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected]': {}
'@babel/[email protected]':
dependencies:
'@babel/code-frame': 7.29.7
@@ -2685,6 +2770,27 @@ snapshots:
'@tanstack/[email protected]': {}
'@testing-library/[email protected]':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
lz-string: 1.5.0
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/[email protected](@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@babel/runtime': 7.29.7
'@testing-library/dom': 10.4.1
react: 19.2.7
react-dom: 19.2.7([email protected])
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/[email protected])
'@tonconnect/[email protected]':
dependencies:
eventsource: 2.0.2
@@ -2746,6 +2852,8 @@ snapshots:
- supports-color
- utf-8-validate
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@babel/parser': 7.29.7
@@ -2812,6 +2920,12 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/node': 22.19.21
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]))':
dependencies:
'@babel/core': 7.29.7
@@ -2891,6 +3005,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
dequal: 2.0.3
[email protected]:
dependencies:
call-bind: 1.0.9
@@ -2994,6 +3116,10 @@ snapshots:
[email protected]:
optional: true
[email protected]:
dependencies:
'@types/node': 22.19.21
[email protected]:
dependencies:
base64-js: 1.5.1
@@ -3097,8 +3223,12 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -3117,6 +3247,8 @@ snapshots:
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -3228,6 +3360,19 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@types/node': 22.19.21
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
buffer-image-size: 0.6.4
entities: 7.0.1
whatwg-mimetype: 3.0.0
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
[email protected]:
dependencies:
es-define-property: 1.0.1
@@ -3390,6 +3535,8 @@ snapshots:
dependencies:
yallist: 3.1.1
[email protected]: {}
[email protected]:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -3499,6 +3646,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
[email protected]:
dependencies:
ansi-regex: 5.0.1
ansi-styles: 5.2.0
react-is: 17.0.2
[email protected]: {}
[email protected]: {}
@@ -3508,6 +3661,8 @@ snapshots:
react: 19.2.7
scheduler: 0.27.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -3853,7 +4008,7 @@ snapshots:
terser: 5.48.0
yaml: 2.9.0
[email protected](@types/[email protected])([email protected])([email protected])([email protected]):
[email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
@@ -3880,6 +4035,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.9.3
happy-dom: 20.11.2
transitivePeerDependencies:
- jiti
- less
@@ -3896,6 +4052,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
tr46: 0.0.3
+14 -6
View File
@@ -1,12 +1,20 @@
import { defineConfig } from 'vitest/config'
// Single test runner for the whole monorepo. Every `*.test.ts` under any
// package's `src/` runs here — the connect crypto/connector suites (vitest
// describe/it/expect) and the auth / shared / onboarding suites (bare
// `test()` + node:assert). One config, one `pnpm test`, one way.
// Single test runner for the whole monorepo. Every `*.test.ts` and `*.test.tsx`
// under any package's `src/` runs here — the connect crypto/connector suites
// (vitest describe/it/expect), the auth / shared / onboarding suites (bare
// `test()` + node:assert), and the component suites that mount a form and read the
// DOM. One config, one `pnpm test`, one way.
//
// A DOM for everything, rather than per-file annotations. The login surface's whole
// claim is WHICH sign-in method renders WHEN, and that was unverifiable: no .tsx
// pattern was collected and the environment had no document, so the one page-level
// test read Login.tsx as TEXT and matched regexes against it — green with the
// component fully broken, red on an innocuous rename. Everything else here is
// pure and does not care that a window exists.
export default defineConfig({
test: {
include: ['pkgs/**/src/**/*.test.ts', 'apps/**/src/**/*.test.ts'],
environment: 'node',
include: ['pkgs/**/src/**/*.test.ts', 'pkgs/**/src/**/*.test.tsx', 'apps/**/src/**/*.test.ts', 'apps/**/src/**/*.test.tsx'],
environment: 'happy-dom',
},
})