@@ -5,6 +5,7 @@ import { Hono } from "hono";
55import { describeRoute } from "hono-openapi" ;
66import { resolver , validator as vValidator } from "hono-openapi/valibot" ;
77import {
8+ any ,
89 integer ,
910 literal ,
1011 maxValue ,
@@ -13,6 +14,7 @@ import {
1314 nullable ,
1415 number ,
1516 object ,
17+ optional ,
1618 parse ,
1719 picklist ,
1820 pipe ,
@@ -21,12 +23,16 @@ import {
2123 transform ,
2224 union ,
2325 uuid ,
26+ variant ,
2427 type InferOutput ,
2528} from "valibot" ;
29+ import { createSiweMessage } from "viem/siwe" ;
2630
31+ import domain from "@exactly/common/domain" ;
32+ import chain from "@exactly/common/generated/chain" ;
2733import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS" ;
2834import { PLATINUM_PRODUCT_ID , SIGNATURE_PRODUCT_ID } from "@exactly/common/panda" ;
29- import { Address } from "@exactly/common/validation" ;
35+ import { Address , Base64URL } from "@exactly/common/validation" ;
3036
3137import database , { cards , credentials } from "../database" ;
3238import t from "../i18n" ;
@@ -41,9 +47,11 @@ import {
4147 getProcessorDetails ,
4248 getSecrets ,
4349 getUser ,
50+ nonce ,
4451 setPIN ,
4552 updateCard ,
4653 USD_TO_CENTS ,
54+ verify ,
4755} from "../utils/panda" ;
4856import { addCapita , deriveAssociateId } from "../utils/pax" ;
4957import { getAccount , getCardLimitAccount } from "../utils/persona" ;
@@ -111,6 +119,30 @@ const UpdateCard = union([
111119 strictObject ( { data : string ( ) , iv : string ( ) , sessionId : string ( ) } ) ,
112120 transform ( ( patch ) => ( { ...patch , type : "pin" as const } ) ) ,
113121 ) ,
122+ pipe (
123+ variant ( "action" , [
124+ object ( { method : literal ( "siwe" ) , action : literal ( "message" ) } ) ,
125+ object ( { method : literal ( "siwe" ) , action : literal ( "verify" ) , message : string ( ) , signature : string ( ) } ) ,
126+ object ( { method : literal ( "webauthn" ) , action : literal ( "challenge" ) } ) ,
127+ object ( {
128+ method : literal ( "webauthn" ) ,
129+ action : literal ( "verify" ) ,
130+ assertion : object ( {
131+ id : Base64URL ,
132+ rawId : Base64URL ,
133+ response : object ( {
134+ clientDataJSON : Base64URL ,
135+ authenticatorData : Base64URL ,
136+ signature : Base64URL ,
137+ userHandle : optional ( Base64URL ) ,
138+ } ) ,
139+ clientExtensionResults : any ( ) ,
140+ type : literal ( "public-key" ) ,
141+ } ) ,
142+ } ) ,
143+ ] ) ,
144+ transform ( ( signature ) => ( { ...signature , type : "signature" as const } ) ) ,
145+ ) ,
114146] ) ;
115147
116148const WalletCredentialsResponse = object ( {
@@ -124,6 +156,9 @@ const UpdatedCardResponse = union([
124156 object ( {
125157 status : pipe ( picklist ( [ "ACTIVE" , "DELETED" , "FROZEN" ] ) , metadata ( { examples : [ "ACTIVE" , "DELETED" , "FROZEN" ] } ) ) ,
126158 } ) ,
159+ object ( { message : string ( ) } ) ,
160+ object ( { challenge : pipe ( string ( ) , metadata ( { examples : [ "1a2b3c" ] } ) ) } ) ,
161+ object ( { } ) ,
127162] ) ;
128163
129164export default new Hono ( )
@@ -384,11 +419,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
384419 captureException ( error , { level : "error" , contexts : { details : { credentialId, scope : "cardLimit" } } } ) ;
385420 } ) ;
386421 const limit = cardLimitAccount ?. attributes . fields . card_limit_usd ?. value ;
387- const card = await createCard (
388- credential . pandaId ,
389- SIGNATURE_PRODUCT_ID ,
390- limit == null ? undefined : limit * USD_TO_CENTS ,
391- ) ;
422+ const card = await createCard ( credential . pandaId , SIGNATURE_PRODUCT_ID ) ;
392423 let mode = 0 ;
393424 try {
394425 if ( await autoCredit ( account ) ) mode = 1 ;
@@ -545,6 +576,12 @@ async function encryptPIN(pin: string) {
545576 } ,
546577 } ,
547578 } ,
579+ 403 : {
580+ description : "Forbidden" ,
581+ content : {
582+ "application/json" : { schema : resolver ( object ( { code : literal ( "no panda" ) } ) , { errorMode : "ignore" } ) } ,
583+ } ,
584+ } ,
548585 404 : {
549586 description : "Not found" ,
550587 content : {
@@ -569,10 +606,20 @@ async function encryptPIN(pin: string) {
569606 return mutex
570607 . runExclusive ( async ( ) => {
571608 const credential = await database . query . credentials . findFirst ( {
572- columns : { account : true } ,
609+ columns : {
610+ account : true ,
611+ pandaId : true ,
612+ factory : true ,
613+ publicKey : true ,
614+ transports : true ,
615+ counter : true ,
616+ } ,
573617 where : eq ( credentials . id , credentialId ) ,
574618 with : {
575- cards : { columns : { id : true , mode : true , status : true } , where : ne ( cards . status , "DELETED" ) } ,
619+ cards : {
620+ columns : { id : true , mode : true , status : true , lastFour : true } ,
621+ where : ne ( cards . status , "DELETED" ) ,
622+ } ,
576623 } ,
577624 } ) ;
578625 if ( ! credential ) return c . json ( { code : "no credential" } , 500 ) ;
@@ -619,6 +666,66 @@ async function encryptPIN(pin: string) {
619666 }
620667 return c . json ( { data, iv } satisfies InferOutput < typeof UpdatedCardResponse > , 200 ) ;
621668 }
669+ case "signature" :
670+ switch ( patch . method ) {
671+ case "siwe" :
672+ switch ( patch . action ) {
673+ case "message" : {
674+ if ( ! credential . pandaId ) return c . json ( { code : "no panda" } , 403 ) ;
675+ const { nonce : value } = await nonce ( credential . pandaId ) ;
676+ const message = createSiweMessage ( {
677+ domain,
678+ address : parse ( Address , credentialId ) ,
679+ statement : `I authorize the account ${ account } to be linked with the card ending in ${ card . lastFour } for my user (${ credential . pandaId } )` ,
680+ uri : `https://${ domain } ` ,
681+ version : "1" ,
682+ chainId : chain . id ,
683+ nonce : value ,
684+ issuedAt : new Date ( ) ,
685+ } ) ;
686+ return c . json ( { message } satisfies InferOutput < typeof UpdatedCardResponse > , 200 ) ;
687+ }
688+ case "verify" :
689+ if ( ! credential . pandaId ) return c . json ( { code : "no panda" } , 403 ) ;
690+ await verify ( credential . pandaId , {
691+ message : patch . message ,
692+ signature : patch . signature ,
693+ authType : "siwe" ,
694+ } ) ;
695+ return c . json ( { } , 200 ) ;
696+
697+ default :
698+ return c . json ( { code : "bad request" } , 400 ) ;
699+ }
700+
701+ case "webauthn" : {
702+ const statement = `I authorize the account ${ account } to be linked with the card ending in ${ card . lastFour } for my user (${ credential . pandaId } )` ;
703+ switch ( patch . action ) {
704+ case "challenge" :
705+ if ( ! credential . pandaId ) return c . json ( { code : "no panda" } , 403 ) ;
706+ return c . json ( { challenge : statement } satisfies InferOutput < typeof UpdatedCardResponse > , 200 ) ;
707+ case "verify" :
708+ if ( ! credential . pandaId ) return c . json ( { code : "no panda" } , 403 ) ;
709+ await verify ( credential . pandaId , {
710+ authType : "webauthn" ,
711+ credential : {
712+ publicKey : { type : "Buffer" , data : [ ...credential . publicKey ] } ,
713+ transports : credential . transports ,
714+ counter : credential . counter ,
715+ } ,
716+ assertion : patch . assertion ,
717+ factory : credential . factory ,
718+ statement,
719+ challenge : statement ,
720+ } ) ;
721+ return c . json ( { } , 200 ) ;
722+
723+ default :
724+ return c . json ( { code : "bad request" } , 400 ) ;
725+ }
726+ }
727+ }
728+ break ;
622729 }
623730 } )
624731 . finally ( ( ) => {
0 commit comments