diff --git a/api/auth/verify.js b/api/auth/verify.js
index 47c6bb1..c154dbb 100644
--- a/api/auth/verify.js
+++ b/api/auth/verify.js
@@ -2,15 +2,40 @@
const { URL } = require('node:url');
-const ALLOWED_CHAIN_IDS = new Set((process.env.SIWE_ALLOWED_CHAIN_IDS || '1,8453,11155111').split(',').map((v) => Number(v.trim())).filter(Number.isFinite));
-const ALLOWED_DOMAIN = process.env.SIWE_ALLOWED_DOMAIN || '';
-const ALLOWED_URI = process.env.SIWE_ALLOWED_URI || '';
const REQUIRED_STATEMENT = 'CommandLayer Claim activation';
+function isDev() {
+ return process.env.NODE_ENV === 'development';
+}
+
function getHost(req) {
return String((req.headers && (req.headers['x-forwarded-host'] || req.headers.host)) || '').split(',')[0].trim().toLowerCase();
}
+function getAllowedDomain(req) {
+ const configured = process.env.COMMANDLAYER_SIWE_DOMAIN || process.env.SIWE_ALLOWED_DOMAIN || '';
+ if (configured) return configured.toLowerCase();
+ const host = getHost(req).split(':')[0];
+ if (isDev() && (host === 'localhost' || host === '127.0.0.1')) return host;
+ return '';
+}
+
+function getAllowedUri(req) {
+ const configured = process.env.COMMANDLAYER_SITE_URL || process.env.SIWE_ALLOWED_URI || '';
+ if (configured) {
+ try { return new URL(configured).toString(); } catch { return configured; }
+ }
+ const host = getHost(req);
+ if (isDev() && host.startsWith('localhost')) return `http://${host}/`;
+ if (isDev() && host.startsWith('127.0.0.1')) return `http://${host}/`;
+ return '';
+}
+
+function getAllowedChainIds() {
+ const raw = process.env.COMMANDLAYER_SIWE_CHAIN_IDS || process.env.SIWE_ALLOWED_CHAIN_IDS || '1,8453';
+ return new Set(raw.split(',').map((v) => Number(v.trim())).filter(Number.isFinite));
+}
+
module.exports = async function handler(req, res) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
@@ -36,20 +61,26 @@ module.exports = async function handler(req, res) {
try {
const parsed = new SiweMessage(message);
- const expectedDomain = ALLOWED_DOMAIN || getHost(req);
- if (expectedDomain && String(parsed.domain || '').toLowerCase() !== expectedDomain) {
+ const expectedDomain = getAllowedDomain(req);
+ const expectedUri = getAllowedUri(req);
+ const allowedChains = getAllowedChainIds();
+
+ if (!expectedDomain) {
+ return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE domain policy is not configured.' });
+ }
+ if (String(parsed.domain || '').toLowerCase() !== expectedDomain) {
return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE domain mismatch.' });
}
- if (ALLOWED_URI && parsed.uri !== ALLOWED_URI) {
+ if (expectedUri && parsed.uri !== expectedUri) {
return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE URI mismatch.' });
}
- if (!ALLOWED_CHAIN_IDS.has(Number(parsed.chainId))) {
+ if (!allowedChains.has(Number(parsed.chainId))) {
return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Unsupported SIWE chainId.' });
}
- if (!String(parsed.statement || '').toLowerCase().includes(REQUIRED_STATEMENT.toLowerCase())) {
+ if (String(parsed.statement || '').trim() !== REQUIRED_STATEMENT) {
return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Invalid SIWE statement for claim activation.' });
}
diff --git a/public/claim.html b/public/claim.html
index caf0307..b49852e 100644
--- a/public/claim.html
+++ b/public/claim.html
@@ -784,21 +784,22 @@
Payment and provisioning are coming next.
}
async function signInWithEthereum(){
- if(!window.ethereum){ alert('No Ethereum wallet detected. Install a wallet extension first.'); return; }
- updateSiweModeHint();
const statusEl=document.getElementById('siweStatus');
const walletEl=document.getElementById('siweWallet');
- statusEl.textContent='Status: Requesting signature...';
- const [address]=await window.ethereum.request({ method:'eth_requestAccounts' });
- const nonceRes=await fetch('/api/auth/nonce',{method:'GET',headers:{'Accept':'application/json'}});
- const nonceData=await nonceRes.json();
- if(!nonceData.ok){ throw new Error('Nonce request failed'); }
- const domain=window.location.host;
- const uri=window.location.origin;
- const chainHex=await window.ethereum.request({ method:'eth_chainId' });
- const chainId=parseInt(chainHex,16);
- const issuedAt=new Date().toISOString();
- const msg=`${domain} wants you to sign in with your Ethereum account:
+ try {
+ if(!window.ethereum){ throw new Error('NO_WALLET'); }
+ updateSiweModeHint();
+ statusEl.textContent='Status: Requesting signature...';
+ const [address]=await window.ethereum.request({ method:'eth_requestAccounts' });
+ const nonceRes=await fetch('/api/auth/nonce',{method:'GET',headers:{'Accept':'application/json'}});
+ const nonceData=await nonceRes.json();
+ if(!nonceData.ok){ throw new Error('NONCE_FAILED'); }
+ const domain=window.location.host;
+ const uri=window.location.origin;
+ const chainHex=await window.ethereum.request({ method:'eth_chainId' });
+ const chainId=parseInt(chainHex,16);
+ const issuedAt=new Date().toISOString();
+ const msg=`${domain} wants you to sign in with your Ethereum account:
${address}
CommandLayer Claim activation
@@ -808,13 +809,22 @@ Payment and provisioning are coming next.
Chain ID: ${chainId}
Nonce: ${nonceData.nonce}
Issued At: ${issuedAt}`;
- const signature=await window.ethereum.request({method:'personal_sign',params:[msg,address]});
- const verifyRes=await fetch('/api/auth/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,signature})});
- const verify=await verifyRes.json();
- if(!verify.ok){ throw new Error(verify.error||'Authentication failed'); }
- state.authenticatedAddress=verify.address; state.authStatus=verify.status; state.authChainId=verify.chainId;
- statusEl.textContent='Status: Authenticated';
- walletEl.textContent=`Connected wallet: ${shortAddr(verify.address)}`;
+ const signature=await window.ethereum.request({method:'personal_sign',params:[msg,address]});
+ const verifyRes=await fetch('/api/auth/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,signature})});
+ const verify=await verifyRes.json();
+ if(!verify.ok){ throw new Error(verify.error||'Authentication failed'); }
+ state.authenticatedAddress=verify.address; state.authStatus=verify.status; state.authChainId=verify.chainId;
+ statusEl.textContent='Status: Authenticated';
+ walletEl.textContent=`Connected wallet: ${shortAddr(verify.address)}`;
+ } catch (err) {
+ const msg=(err && err.message)||'';
+ if(msg==='NO_WALLET') statusEl.textContent='Status: No wallet found.';
+ else if(msg.includes('User rejected') || msg.includes('rejected')) statusEl.textContent='Status: Wallet rejected signature.';
+ else if(msg.includes('dependency unavailable')) statusEl.textContent='Status: Auth dependency unavailable.';
+ else if(msg.includes('domain mismatch')) statusEl.textContent='Status: Domain mismatch.';
+ else if(msg.includes('signature') || msg.includes('SIWE')) statusEl.textContent='Status: Signature invalid.';
+ else statusEl.textContent='Status: Authentication failed.';
+ }
}
function goStep5Auth(){
diff --git a/tests/api-auth.test.js b/tests/api-auth.test.js
index 04ebc07..d239118 100644
--- a/tests/api-auth.test.js
+++ b/tests/api-auth.test.js
@@ -40,3 +40,11 @@ test('POST /api/auth/verify rejects malformed message/signature', async () => {
assert.equal(res.body.ok, false);
assert.equal(res.body.status, 'AUTH_FAILED');
});
+
+
+test('POST /api/auth/verify surfaces dependency unavailable when siwe is missing', async () => {
+ const res = makeRes();
+ await verifyHandler({ method: 'POST', body: { message: 'x', signature: '0xy' }, headers: { host: 'localhost:3000' } }, res);
+ assert.equal(res.statusCode, 503);
+ assert.match(res.body.error, /dependency unavailable/i);
+});